Files
fofos-admin/web/RichText.tsx
T
pastilhasandClaude Opus 5 179507fb7b the rich text editor offers three heading levels
One button that toggled H2, so a document could hold exactly one weight of heading — and the levels the
site's sanitiser already accepted were unreachable from the editor that writes for it.

Three buttons rather than a dropdown: they are toggles like everything else on the bar, the active one
answers "what is this line", and a select would hide that behind a click.

A level is a rank in the outline and never a size. What each draws at is the site's — three of the four
type roles, in the prose rules — so the Layout section stays the one place type is sized.

`[open]` H1 is offered because it was asked for, and a page that already has a title now has two. That
is an outline a search engine reads as two documents, and it wants a rule about where H1 belongs before
this gets used much.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 16:55:48 +01:00

297 lines
11 KiB
TypeScript

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,
Heading1,
Heading2,
Heading3,
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.
*/
/**
* The heading levels the bar offers, in the order they nest.
*
* A level is a rank in the document's outline, and the SIZE it draws at is the site's — see the prose
* rules in styles.css, where each maps to one of the four type roles. So this list says what a heading
* means and never what it looks like, which is the only way the Layout section can go on being the
* place type is sized.
*
* `[open]` H1 is offered because it was asked for, and a page that already has a title now has two —
* which is an outline a search engine reads as two documents. Worth a rule about where H1 belongs
* before this is used much.
*/
const HEADINGS = [
{ level: 1 as const, Icon: Heading1, label: 'Heading 1' },
{ level: 2 as const, Icon: Heading2, label: 'Heading 2' },
{ level: 3 as const, Icon: Heading3, label: 'Heading 3' },
];
const Tool = ({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
children: React.ReactNode;
}) => (
<button
type="button"
onMouseDown={(event) => {
// The editor loses focus on mousedown otherwise, and a command applied with no selection does
// nothing — which reads as a dead button.
event.preventDefault();
onClick();
}}
disabled={disabled}
aria-label={label}
title={label}
className={`rounded p-1.5 transition-colors disabled:opacity-40 ${
active ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{children}
</button>
);
/**
* 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 }) => (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-1 py-1">
<Tool label="Bold" onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}>
<Bold className="size-3.5" />
</Tool>
<Tool label="Italic" onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}>
<Italic className="size-3.5" />
</Tool>
<Tool
label="Strikethrough"
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
>
<Strikethrough className="size-3.5" />
</Tool>
<span className="mx-1 h-4 w-px bg-border" />
{/*
Three levels, not one.
It was a single button that toggled H2, so a document could hold exactly one weight of heading —
and the two levels the site's sanitiser already allowed were unreachable from the editor that
writes for it.
Three buttons rather than a dropdown: they are toggles like everything else on this bar, the
active one is the answer to "what is this line", and a select would hide that behind a click.
*/}
{HEADINGS.map(({ level, Icon, label }) => (
<Tool
key={level}
label={label}
onClick={() => editor.chain().focus().toggleHeading({ level }).run()}
active={editor.isActive('heading', { level })}
>
<Icon className="size-3.5" />
</Tool>
))}
<Tool
label="Bulleted list"
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
>
<List className="size-3.5" />
</Tool>
<Tool
label="Numbered list"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
>
<ListOrdered className="size-3.5" />
</Tool>
<span className="mx-1 h-4 w-px bg-border" />
{/*
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 }) => (
<Tool
key={value}
label={`Align ${value}`}
onClick={() => editor.chain().focus().setTextAlign(value).run()}
active={editor.isActive({ textAlign: value })}
>
<Icon className="size-3.5" />
</Tool>
))}
<span className="mx-1 h-4 w-px bg-border" />
<Tool
label="Link"
active={editor.isActive('link')}
onClick={() => {
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();
}}
>
<Link2 className="size-3.5" />
</Tool>
<span className="ml-auto flex items-center gap-0.5">
<Tool label="Undo" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
<Undo2 className="size-3.5" />
</Tool>
<Tool label="Redo" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
<Redo2 className="size-3.5" />
</Tool>
</span>
</div>
);
/**
* 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 (
<div className="overflow-hidden rounded-md border border-border bg-background">
<Toolbar editor={editor} />
<EditorContent editor={editor} />
{!value && placeholder && (
<p className="pointer-events-none -mt-9 px-3 text-xs text-muted-foreground/50">{placeholder}</p>
)}
</div>
);
};