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, AlignJustify, 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, four 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; }) => ( ); /** * The alignments the toolbar offers. * * The same four the site's sanitiser accepts — ALIGN_CLASSES in src/rich-text.ts. A fifth offered here * would be stripped on save, silently, leaving somebody pressing a button that appears to do nothing. */ const ALIGNMENTS = [ { value: 'left' as const, Icon: AlignLeft }, { value: 'center' as const, Icon: AlignCenter }, { value: 'right' as const, Icon: AlignRight }, { value: 'justify' as const, Icon: AlignJustify }, ]; 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')} > {/* Four alignments, justify included. The site has always been able to render it: `rt-justify` is in the sanitiser's allowlist and styles.css gives it `text-align: justify` with `hyphens: auto` — hyphenation being what stops justified text opening rivers of white space between words. Only the toolbar was missing. */} {ALIGNMENTS.map(({ value, Icon }) => ( editor.chain().focus().setTextAlign(value).run()} active={editor.isActive({ textAlign: value })} > ))} { 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()}>
); /** * Stored alignment classes, back into the inline styles tiptap understands. * * ── The round trip was LOSSY, and this is the half that was missing ── * * The editor emits `style="text-align: right"`. The site's sanitiser refuses style attributes — one is * a way to position something over the rest of the page — and converts alignment to `class="rt-right"` * on the way in. That half has always worked. * * Coming back out, tiptap parses styles and knows nothing about those classes. So a document loaded * from the database arrived with every alignment gone, and the next save wrote that loss back. Editing * one paragraph silently un-aligned the others: a right-aligned line lost its alignment because * somebody justified something else. * * Converting here makes the two ends symmetric — class in, style out, style in, class out — and the * conversion is the sanitiser's own table read backwards. * * `rt-font-*` is NOT converted, and that is a known gap rather than an oversight: this editor has no * font control, so a stored font class has nothing to become and would be dropped either way. It wants * a font button before it wants a parser. */ const ALIGNMENT_CLASS = /class="rt-(left|center|right|justify)"/g; const toEditorHtml = (html: string): string => html.replace(ALIGNMENT_CLASS, (_whole, align: string) => `style="text-align: ${align}"`); 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: toEditorHtml(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) return; // Compared in the EDITOR's dialect, or every load looks like a change: the stored html says // `class="rt-right"` and getHTML() says `style="text-align: right"`, which never match. const incoming = toEditorHtml(value); if (incoming !== editor.getHTML()) editor.commands.setContent(incoming, { emitUpdate: false }); }, [editor, value]); if (!editor) return null; return (
{!value && placeholder && (

{placeholder}

)}
); };