diff --git a/manifest.ts b/manifest.ts index 0c1e823..024f292 100644 --- a/manifest.ts +++ b/manifest.ts @@ -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', diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 626c638..cf801f6 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -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 = ( <> diff --git a/web/RichText.tsx b/web/RichText.tsx new file mode 100644 index 0000000..5322f7b --- /dev/null +++ b/web/RichText.tsx @@ -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; +}) => ( + +); + +const Toolbar = ({ editor }: { editor: Editor }) => ( +
+ editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}> + + + editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}> + + + editor.chain().focus().toggleStrike().run()} + active={editor.isActive('strike')} + > + + + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + active={editor.isActive('heading', { level: 2 })} + > + + + editor.chain().focus().toggleBulletList().run()} + active={editor.isActive('bulletList')} + > + + + editor.chain().focus().toggleOrderedList().run()} + active={editor.isActive('orderedList')} + > + + + + + + {(['left', 'center', 'right'] as const).map((align) => ( + editor.chain().focus().setTextAlign(align).run()} + active={editor.isActive({ textAlign: align })} + > + {align === 'left' ? ( + + ) : align === 'center' ? ( + + ) : ( + + )} + + ))} + + + + { + 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(); + }} + > + + + + + editor.chain().focus().undo().run()} disabled={!editor.can().undo()}> + + + editor.chain().focus().redo().run()} disabled={!editor.can().redo()}> + + + +
+); + +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 ( +
+ + + {!value && placeholder && ( +

{placeholder}

+ )} +
+ ); +}; diff --git a/web/sections/HomePanel.tsx b/web/sections/HomePanel.tsx index 9506c5f..9a3cbe9 100644 --- a/web/sections/HomePanel.tsx +++ b/web/sections/HomePanel.tsx @@ -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 = () => {

Intro

-