Files
fofos-admin/web/RichText.tsx
T
pastilhasandClaude Opus 5 5e351aa19f the editor reads back the alignment it wrote
Editing one paragraph silently un-aligned the others. A right-aligned line lost its alignment because
somebody justified something else — which reads as the alignment buttons applying to the whole document
rather than the selection, and is not that at all.

THE ROUND TRIP WAS LOSSY IN ONE DIRECTION. 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"`. That half 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 already gone, and the next save wrote the loss back. Whatever
you set in the current session survived; everything set before it did not.

Converting classes to styles as the editor is seeded makes the two ends symmetric, and the conversion is
the sanitiser's own table read backwards. The comparison in the re-seed effect had to move into the
editor's dialect too, or every load looks like a change: the stored html and getHTML() never match.

`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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:14:05 +01:00

264 lines
9.4 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,
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;
}) => (
<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" />
<Tool
label="Heading"
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
>
<Heading2 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>
);
};