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 (