Files
fofos-admin/web/RichText.tsx
T
pastilhasandClaude Opus 5 5c2d3baac0 justify, which the site could always render
`rt-justify` has been in the sanitiser's allowlist all along, 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 it.

The four are now a list beside the toolbar rather than a ternary chain, and the comment says what it has
to match: ALIGN_CLASSES in the site's rich-text.ts. A fifth offered here would be stripped on save,
silently, leaving somebody pressing a button that appears to do nothing.

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

234 lines
7.8 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>
);
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 (
<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>
);
};