import { useEffect } from 'react'; import { EditorContent, useEditor, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; import TextAlign from '@tiptap/extension-text-align'; import { AlignCenter, AlignLeft, AlignRight, Bold, Heading2, Italic, Link2, List, ListOrdered, Redo2, Strikethrough, Undo2, } from 'lucide-react'; /** * The editor for the site's copy. * * ── Why HTML in and HTML out ── * * `intro_text` and the rest are already HTML in the site's database, written by the old admin and * rendered straight into the page. So the editor's job is to read that and give it back — not to * introduce a second format with a conversion at each end, which is a migration and a class of bug * (round-tripping) bought for nothing. * * ── The toolbar is what the site can actually render ── * * Bold, italic, strike, a heading, two lists, three alignments, a link. Not because tiptap cannot do * more, but because anything offered here that the site's stylesheet has no rule for produces copy that * looks right while editing and wrong when published — the worst kind of editor, since the person only * finds out after saying it is done. * * ── Uncontrolled after the first render, deliberately ── * * tiptap owns its own document. Pushing `value` in on every render would move the caret to the end * mid-sentence, which is the classic controlled-contentEditable bug. So the parent's value seeds it, * and after that the editor is the source of truth until it is unmounted or the value changes from * somewhere that is not typing — see the effect below. */ const Tool = ({ onClick, active, disabled, label, children, }: { onClick: () => void; active?: boolean; disabled?: boolean; label: string; children: React.ReactNode; }) => ( ); const Toolbar = ({ editor }: { editor: Editor }) => (
editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}> editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}> editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} > editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} > editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} > editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} > {(['left', 'center', 'right'] as const).map((align) => ( editor.chain().focus().setTextAlign(align).run()} active={editor.isActive({ textAlign: align })} > {align === 'left' ? ( ) : align === 'center' ? ( ) : ( )} ))} { if (editor.isActive('link')) { editor.chain().focus().unsetLink().run(); return; } // `prompt` rather than a dialog: it is one string, it is modal either way, and a bespoke // dialog here would be a component to maintain for a field with no validation worth doing. const href = window.prompt('Link to'); if (href) editor.chain().focus().setLink({ href }).run(); }} > editor.chain().focus().undo().run()} disabled={!editor.can().undo()}> editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
); export const RichText = ({ value, onChange, placeholder, }: { value: string; onChange: (html: string) => void; placeholder?: string; }) => { const editor = useEditor({ extensions: [ StarterKit, TextAlign.configure({ types: ['heading', 'paragraph'] }), // `openOnClick: false` — inside an editor a click is a cursor, not navigation. Without it, // clicking a link to edit its text opens it and loses whatever was unsaved. Link.configure({ openOnClick: false }), ], content: value, editorProps: { attributes: { class: 'prose prose-sm prose-invert max-w-none min-h-40 px-3 py-2 focus:outline-none [&_a]:text-primary [&_a]:underline', }, }, onUpdate: ({ editor: current }) => onChange(current.getHTML()), }); /* Only when the value changes from somewhere that is NOT this editor. Comparing against `getHTML()` first is what makes that distinction: while somebody types, the two already agree, so nothing is written back and the caret stays where it is. When a save returns the server's version — or the panel loads a different record — they differ, and the document is replaced. Without the comparison this is the classic controlled-contentEditable bug: the caret jumps to the end on every keystroke. */ useEffect(() => { if (editor && value !== editor.getHTML()) editor.commands.setContent(value, { emitUpdate: false }); }, [editor, value]); if (!editor) return null; return (
{!value && placeholder && (

{placeholder}

)}
); };