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 TextAlign from '@tiptap/extension-text-align' 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]) } // Alignment glyph: three lines justified to the given side. function AlignIcon({ align }) { const rows = { left: [[2, 14], [2, 10], [2, 12]], center: [[2, 14], [4, 12], [3, 13]], right: [[2, 14], [6, 14], [4, 14]], }[align] return ( ) } // Toolbar variants: // 'full' — every control, incl. the internal wiki-page link picker (wiki use). // 'post' — full minus the wiki-page picker (no page-list context in posts). // 'minimal' — image upload only; text formatting stripped (Screenshots captions). export default function RichTextEditor({ value, onChange, pages = [], variant = 'full' }) { const showText = variant !== 'minimal' // bold/italic/strike, headings, lists, quotes, links const showWikiLink = variant === 'full' && pages.length > 0 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 }), // Alignment stored as `text-align` on the block node (heading/paragraph), // so it round-trips through save/reload as inline style. Shared here means // every consumer — post editor, and the future rich_text / two_column // blocks — gets it for free. TextAlign.configure({ types: ['heading', 'paragraph'] }), ], 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 (