Free Text, written from inside the page

Every column gets Add component; every component in one gets Edit and Delete, drawn ON the rendered
thing rather than replacing it. An editor that swaps content for a placeholder is showing you the
editor, not the page — and free text with nothing in it renders as nothing, so the outline is the only
thing saying it is there.

Add component opens the editor STRAIGHT AWAY. Free Text is the only kind, and one option is not a
choice: a picker offering it would be a click asking to be got past. The picker arrives with the second
kind, which is when it starts meaning something.

The add button is quieter than Add section. This is the inner loop, and a control as loud as the one
that adds a whole band would make every column shout.

── The dialog, and the limitation worth naming ──

Editing happens in a modal, not in the page. In-place is the obvious idea and a much bigger one: the
toolbar would have to live in a document the site owns, the site's typography would fight it, and every
keystroke would cross into another document. The dialog is where a working editor already exists, and
it costs a modal instead of a rewrite.

So what you type is not laid out in the page until you save. The preview reloads immediately after, so
the loop is short — but it is a loop, and direct manipulation is what a page builder eventually wants.

The editor is keyed on the stored html, so opening a different component rebuilds it rather than
reusing one still holding the last component's document. Empty text saves: clearing a paragraph is
deleting its words, not abandoning the edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 04:38:27 +01:00
co-authored by Claude Opus 5
parent a79dc2b1af
commit b7c887a9e8
3 changed files with 265 additions and 11 deletions
+114 -8
View File
@@ -42,12 +42,25 @@ const MARK = 'data-fofos-admin-canvas';
/** The editor's own colour. Deliberately not the site's — this is chrome, and it should not blend in. */
const ACCENT = '#3b82f6';
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two' };
/**
* A component, as the canvas needs it.
*
* `content` is carried even though nothing in this file draws it: the panel reads it back when somebody
* presses Edit, and it arrived with the section. Fetching it again on every Edit would be a request to
* be told something already in hand.
*/
export type CanvasComponent = { id: number; column: number; kind: string; content?: { html?: string } };
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two'; components: CanvasComponent[] };
export type Canvas = {
sections: CanvasSection[];
onAddSection: () => void;
onDeleteSection: (section: CanvasSection) => void;
/** A column wants something in it. */
onAddComponent: (section: CanvasSection, column: number) => void;
/** Something already in a column was pressed. */
onEditComponent: (component: CanvasComponent) => void;
onDeleteComponent: (component: CanvasComponent) => void;
};
/**
@@ -181,14 +194,107 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
identical until something is in them — which is exactly when it is too late to notice you chose
the wrong one.
*/
element.querySelectorAll<HTMLElement>('[data-column]').forEach((column) => {
const was = column.style.outline;
const wasOffset = column.style.outlineOffset;
column.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
column.style.outlineOffset = '-5px';
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
const was = columnElement.style.outline;
const wasOffset = columnElement.style.outlineOffset;
columnElement.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
columnElement.style.outlineOffset = '-5px';
undo.push(() => {
column.style.outline = was;
column.style.outlineOffset = wasOffset;
columnElement.style.outline = was;
columnElement.style.outlineOffset = wasOffset;
});
/*
Every component in this column gets a handle.
Drawn ON the rendered component rather than replacing it — an editor that swaps content for a
placeholder is showing you the editor, not the page. Free text with nothing written in it
renders as nothing, so the outline is the only thing saying it is there at all.
*/
const mine = section.components.filter((component) => component.column === columnIndex);
for (const component of mine) {
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
if (!node) continue;
const wasPosition = node.style.position;
const wasOutline = node.style.outline;
node.style.position = 'relative';
node.style.outline = `1px dotted color-mix(in srgb, ${ACCENT} 60%, transparent)`;
undo.push(() => {
node.style.position = wasPosition;
node.style.outline = wasOutline;
});
const tools = document.createElement('div');
tools.setAttribute(MARK, 'component-tools');
style(tools, [
'position:absolute',
'top:0',
'right:0',
'display:flex',
'gap:0.5rem',
'padding:0.125rem 0.25rem',
'font-size:0.625rem',
'font-family:ui-sans-serif, system-ui, sans-serif',
`color:${ACCENT}`,
'background:var(--site-bg, #fff)',
]);
for (const [label, run] of [
['Edit', () => canvas.onEditComponent(component)],
['Delete', () => canvas.onDeleteComponent(component)],
] as const) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = label;
style(action, [
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'text-decoration:underline',
]);
action.addEventListener('click', run);
undo.push(() => action.removeEventListener('click', run));
tools.append(action);
}
node.append(tools);
undo.push(() => tools.remove());
}
/*
And a way to put something in it.
One per column, always at the bottom, so a column that already has three paragraphs still has
somewhere obvious to add a fourth. Quieter than Add section — this is the inner loop, and a
button as loud as the one that adds a whole band would make every column shout.
*/
const add = document.createElement('button');
add.setAttribute(MARK, 'add-component');
add.type = 'button';
add.textContent = '+ Add component';
style(add, [
'display:block',
'width:100%',
'margin-top:0.75rem',
'padding:0.5rem',
'cursor:pointer',
'background:none',
`border:1px dashed color-mix(in srgb, ${ACCENT} 40%, transparent)`,
'border-radius:0.375rem',
`color:${ACCENT}`,
'font-family:ui-sans-serif, system-ui, sans-serif',
'font-size:0.75rem',
]);
const onAdd = () => canvas.onAddComponent(section, columnIndex);
add.addEventListener('click', onAdd);
columnElement.append(add);
undo.push(() => {
add.removeEventListener('click', onAdd);
add.remove();
});
});
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { RichText } from '../RichText';
/**
* Writing the words in a Free Text component.
*
* ── Why a dialog and not in-place on the page ──
*
* Editing inside the frame is the obvious idea and it is a much bigger one: the editor's toolbar would
* have to live in a document the site owns, the site's own typography would fight it, and every
* keystroke would cross into another document. The dialog is where the plugin already has a working
* editor, and it costs a modal instead of a rewrite.
*
* It is a real limitation and worth naming: what you type is not laid out in the page until you save.
* The preview reloads immediately after, so the loop is short — but it is a loop rather than direct
* manipulation, and direct manipulation is what a page builder eventually wants.
*/
export const FreeTextDialog = ({
open,
busy,
initial,
onCancel,
onSave,
}: {
open: boolean;
busy: boolean;
/** What is stored today. Empty for a component that was only just added. */
initial: string;
onCancel: () => void;
onSave: (html: string) => void;
}) => {
const [html, setHtml] = useState(initial);
// Reset from the stored value on every open, so a cancelled edit is genuinely cancelled rather than
// waiting in the box for the next time somebody opens it.
useEffect(() => {
if (open) setHtml(initial);
}, [open, initial]);
return (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Text</DialogTitle>
</DialogHeader>
{/* Keyed on `initial`, so opening a DIFFERENT component rebuilds the editor rather than
reusing one still holding the last component's document. */}
<div className="min-h-0 max-h-[60vh] overflow-auto">
<RichText key={initial} value={html} onChange={setHtml} placeholder="Write something." />
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={busy}>
Cancel
</Button>
{/* No disabled-when-empty here, unlike the section name: empty text is a legitimate thing to
save. Somebody clearing a paragraph is deleting its words, not abandoning the edit. */}
<Button onClick={() => onSave(html)} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Save text
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+81 -3
View File
@@ -4,8 +4,9 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { SitePreview, useDevPort } from '../SitePreview';
import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasSection } from '../canvas';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextDialog } from './FreeTextDialog';
import { usePages, type Page } from '../usePages';
/**
@@ -50,9 +51,19 @@ export const PageBuilderPanel = () => {
so putting them in the dependency list would repaint on every render; through a ref they are always
the current ones without being part of the identity.
*/
const handlersRef = useRef<{ add: () => void; remove: (id: number) => void }>({ add: () => {}, remove: () => {} });
const handlersRef = useRef<{
add: () => void;
remove: (id: number) => void;
addComponent: (section: CanvasSection, column: number) => void;
editComponent: (component: CanvasComponent) => void;
removeComponent: (component: CanvasComponent) => void;
}>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} });
const [adding, setAdding] = useState(false);
const [creating, setCreating] = useState(false);
/** The component being written, and where a new one would go. Null when the dialog is shut. */
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
null,
);
/*
Bumped after a write, and passed to SitePreview to remount the frame.
@@ -86,6 +97,9 @@ export const PageBuilderPanel = () => {
sections,
onAddSection: () => handlersRef.current.add(),
onDeleteSection: (section) => handlersRef.current.remove(section.id),
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
onEditComponent: (component) => handlersRef.current.editComponent(component),
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
});
},
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
@@ -162,7 +176,63 @@ export const PageBuilderPanel = () => {
// Assigned on every render, after both functions above exist, so the ref always holds the current
// pair without either being part of `onDocument`'s identity.
handlersRef.current = { add: () => setAdding(true), remove: (sectionId: number) => void deleteSection(sectionId) };
/*
Free Text is the only kind, so Add component opens the editor straight away.
No picker in between: one option is not a choice, and a dialog that offers it is a click asking to
be got past. The picker arrives with the second kind, which is the moment it starts meaning
something.
*/
const addComponent = (section: CanvasSection, column: number) =>
setEditing({ id: null, sectionId: section.id, column, html: '' });
const editComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
const stored = owner?.components.find((one) => one.id === component.id);
const html = typeof stored?.content?.html === 'string' ? stored.content.html : '';
setEditing({ id: component.id, sectionId: owner?.id ?? 0, column: component.column, html });
};
const saveText = async (html: string) => {
if (!page || !editing) return;
setCreating(true);
setError(null);
const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`;
try {
if (editing.id === null) {
await clientRef.current.post(base, { kind: 'free-text', column: editing.column, content: { html } });
} else {
await clientRef.current.patch(`${base}/${editing.id}`, { content: { html } });
}
setEditing(null);
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
} finally {
setCreating(false);
}
};
const deleteComponent = async (component: CanvasComponent) => {
if (!page) return;
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
setError(null);
try {
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`);
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
handlersRef.current = {
add: () => setAdding(true),
remove: (sectionId: number) => void deleteSection(sectionId),
addComponent,
editComponent,
removeComponent: (component) => void deleteComponent(component),
};
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
@@ -192,6 +262,14 @@ export const PageBuilderPanel = () => {
onCancel={() => setAdding(false)}
onCreate={(values) => void createSection(values)}
/>
<FreeTextDialog
open={editing !== null}
busy={creating}
initial={editing?.html ?? ''}
onCancel={() => setEditing(null)}
onSave={(html) => void saveText(html)}
/>
</div>
);
};