import { useEffect, useRef, 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 { api } from '../api/client.js' // Toolbar button. function Btn({ onClick, active, disabled, title, children }) { return ( ) } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) } export default function RichTextEditor({ value, onChange, pages = [] }) { const fileRef = useRef(null) const [uploading, setUploading] = useState(false) const [linkMenu, setLinkMenu] = useState(false) const [linkFilter, setLinkFilter] = useState('') const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [2, 3] } }), Link.configure({ openOnClick: false, autolink: true }), Image.configure({ inline: false }), ], content: value || '', onUpdate: ({ editor }) => onChange(editor.getHTML()), }) // Safety net: sync if the parent resets `value` externally (won't fire during // normal typing because the parent value equals what the editor just emitted). useEffect(() => { if (!editor) return if (value != null && value !== editor.getHTML()) { editor.commands.setContent(value, false) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, editor]) if (!editor) return null function setLink() { const prev = editor.getAttributes('link').href || '' const url = window.prompt('Link URL (leave blank to remove)', prev) if (url === null) return if (url === '') return editor.chain().focus().extendMarkRange('link').unsetLink().run() editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() } function insertInternalLink(page) { const { from, to } = editor.state.selection if (from === to) { editor.chain().focus().insertContent(`${escapeHtml(page.title)} `).run() } else { editor.chain().focus().extendMarkRange('link').setLink({ href: `/wiki/${page.slug}` }).run() } setLinkMenu(false) setLinkFilter('') } async function onPickImage(e) { const file = e.target.files?.[0] e.target.value = '' // allow re-selecting the same file if (!file) return setUploading(true) try { const { url } = await api.admin.upload(file) editor.chain().focus().setImage({ src: url, alt: file.name }).run() } catch (err) { alert(err.message || 'Image upload failed.') } finally { setUploading(false) } } return (
editor.chain().focus().toggleBold().run()}> B editor.chain().focus().toggleItalic().run()}> I editor.chain().focus().toggleStrike().run()}> S editor.chain().focus().toggleHeading({ level: 2 }).run()}> H2 editor.chain().focus().toggleHeading({ level: 3 }).run()}> H3 editor.chain().focus().toggleBulletList().run()}> • List editor.chain().focus().toggleOrderedList().run()}> 1. List editor.chain().focus().toggleBlockquote().run()}> ❝ editor.chain().focus().toggleCodeBlock().run()}> {''} editor.chain().focus().setHorizontalRule().run()}> — 🔗 setLinkMenu((v) => !v)}> 📄 fileRef.current?.click()}> {uploading ? '…' : '🖼'} editor.chain().focus().undo().run()}> ↶ editor.chain().focus().redo().run()}> ↷
{linkMenu && (
setLinkFilter(e.target.value)} />
{pages .filter((p) => { const q = linkFilter.trim().toLowerCase() return !q || p.title.toLowerCase().includes(q) || p.slug.includes(q) }) .slice(0, 30) .map((p) => ( ))}
)}
) }