`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>
234 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
};
|