a real editor for the site's copy, and the media param stops following you
Two things. `?media=` is Media's own folder and now goes when you leave it. It survived every navigation, so `?section=home&media=GALLERY/CALAVERA+1` was an ordinary URL — a parameter naming a folder in a section that is not open, carried into every link and shared or bookmarked that way. And the intro is a tiptap editor rather than a textarea of raw HTML. HTML in and HTML out, because that is already what the site's database holds and renders — introducing a second format would buy a migration and a round-tripping bug class for nothing. Declared as the PLUGIN's npmDependencies, not added to the platform's package.json. That lockfile is frozen and shared, and npm-deps.ts exists for exactly this: everything lands in plugins/node_modules/, so a plugin's editor cannot move a version the platform depends on. The toolbar is only what the site's stylesheet can render. Anything more would produce copy that looks right while editing and wrong when published, which the person only discovers after saying it was done. The content effect compares against getHTML() before writing back, and that comparison is the whole thing: while somebody types the two already agree, so nothing is pushed in and the caret stays put. Without it this is the classic controlled-contentEditable bug where the cursor jumps to the end on every keystroke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+19
@@ -48,6 +48,25 @@ export const manifest: PluginManifest = {
|
||||
// Nothing to declare. It talks HTTP to fofos and stores one row; there is no host binary anywhere
|
||||
// on the path, and fofos itself is a separate deployment rather than something this installs.
|
||||
|
||||
/*
|
||||
The rich text editor for the site's copy.
|
||||
|
||||
Declared here rather than added to the platform's package.json, which is frozen and shared: these
|
||||
are installed into `plugins/node_modules/` alongside every other plugin's, so nothing about this
|
||||
plugin's editor can move a version the platform depends on.
|
||||
|
||||
`@tiptap/extension-text-align` was already in the platform's dependencies with nothing importing
|
||||
it — a leftover from an earlier attempt. It is asked for here too, so this plugin's copy is
|
||||
resolved by the same rule as the rest rather than by whatever happens to be up the tree.
|
||||
*/
|
||||
npmDependencies: {
|
||||
'@tiptap/react': '^3.15.3',
|
||||
'@tiptap/starter-kit': '^3.15.3',
|
||||
'@tiptap/pm': '^3.15.3',
|
||||
'@tiptap/extension-text-align': '^3.15.3',
|
||||
'@tiptap/extension-link': '^3.15.3',
|
||||
},
|
||||
|
||||
permissions: [
|
||||
{
|
||||
key: 'fofos-admin',
|
||||
|
||||
@@ -64,6 +64,16 @@ export const NavPanel = () => {
|
||||
const search = new URLSearchParams(params);
|
||||
search.set('section', section.key);
|
||||
|
||||
/*
|
||||
`media` is Media's own folder, and it goes when you leave.
|
||||
|
||||
It survived every navigation, so `?section=home&media=GALLERY/CALAVERA+1` was an ordinary URL —
|
||||
a parameter naming a folder in a section that is not open, carried into every link, and shared
|
||||
or bookmarked that way. It is scoped to one section, so it is that section's to keep and nobody
|
||||
else's to inherit.
|
||||
*/
|
||||
if (section.key !== 'media') search.delete('media');
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
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, three 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>
|
||||
);
|
||||
|
||||
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" />
|
||||
|
||||
{(['left', 'center', 'right'] as const).map((align) => (
|
||||
<Tool
|
||||
key={align}
|
||||
label={`Align ${align}`}
|
||||
onClick={() => editor.chain().focus().setTextAlign(align).run()}
|
||||
active={editor.isActive({ textAlign: align })}
|
||||
>
|
||||
{align === 'left' ? (
|
||||
<AlignLeft className="size-3.5" />
|
||||
) : align === 'center' ? (
|
||||
<AlignCenter className="size-3.5" />
|
||||
) : (
|
||||
<AlignRight 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>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { ArrowDown, ArrowUp, Check, ImagePlus, Loader2, Trash2 } from 'lucide-re
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { readableError } from '../errors';
|
||||
import { useFlash } from '../useFlash';
|
||||
import { RichText } from '../RichText';
|
||||
|
||||
/**
|
||||
* The Home page: a hero, an intro, and an ordered gallery.
|
||||
@@ -174,13 +174,8 @@ export const HomePanel = () => {
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Intro</p>
|
||||
<Textarea
|
||||
value={intro}
|
||||
onChange={(event) => setIntro(event.target.value)}
|
||||
rows={4}
|
||||
className="text-xs"
|
||||
placeholder="The paragraph under the hero."
|
||||
/>
|
||||
{/* HTML in and HTML out — it is what the site's database already holds and renders. */}
|
||||
<RichText value={intro} onChange={setIntro} placeholder="The paragraph under the hero." />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
Reference in New Issue
Block a user