diff --git a/web/canvas.ts b/web/canvas.ts
index a9d074c..a814fde 100644
--- a/web/canvas.ts
+++ b/web/canvas.ts
@@ -265,7 +265,9 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
const wasPosition = node.style.position;
node.style.position = 'relative';
// The outline and the tools' visibility are the stylesheet's, so they can depend on :hover.
- node.setAttribute('data-fofos-component', '');
+ // The ID, not an empty marker: the panel writes a draft into this node as somebody types, and
+ // has to be able to find the one being edited.
+ node.setAttribute('data-fofos-component', String(component.id));
undo.push(() => {
node.style.position = wasPosition;
node.removeAttribute('data-fofos-component');
diff --git a/web/sections/FreeTextDialog.tsx b/web/sections/FreeTextDialog.tsx
deleted file mode 100644
index 436bfde..0000000
--- a/web/sections/FreeTextDialog.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-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 (
-
- );
-};
diff --git a/web/sections/FreeTextEditor.tsx b/web/sections/FreeTextEditor.tsx
new file mode 100644
index 0000000..e817df7
--- /dev/null
+++ b/web/sections/FreeTextEditor.tsx
@@ -0,0 +1,57 @@
+import { Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { RichText } from '../RichText';
+
+/**
+ * Writing the words in a Free Text component, BELOW the page rather than over it.
+ *
+ * ── Why this stopped being a modal ──
+ *
+ * A modal covers the thing being edited. For a page builder that is the whole subject: you are choosing
+ * how words sit in a column, and a dialog in front of the column answers a different question. The
+ * Layout section already put its controls under the preview for the same reason, and this follows it.
+ *
+ * The frame keeps the height it had; the editor takes what it needs from the bottom, the same
+ * arrangement as everything else in these panels.
+ *
+ * ── And the text lands in the page as it is typed ──
+ *
+ * The panel writes each change straight into the component's node in the frame — see the note in
+ * PageBuilderPanel. So the loop is not "type, save, look" any more; it is just looking.
+ */
+export const FreeTextEditor = ({
+ html,
+ busy,
+ onChange,
+ onCancel,
+ onSave,
+}: {
+ html: string;
+ busy: boolean;
+ onChange: (html: string) => void;
+ onCancel: () => void;
+ onSave: () => void;
+}) => (
+
+
+
Text
+
+
+ {/* Not disabled when empty, unlike a section's name: clearing a paragraph is deleting its
+ words, which is a legitimate thing to save. */}
+
+
+
+
+ {/* Capped, so a long piece scrolls in the editor rather than pushing the page off the top of the
+ panel. The page is the thing being judged and it keeps its height. */}
+
+
+
+
+);
diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx
index 0207bd9..c86f7f2 100644
--- a/web/sections/PageBuilderPanel.tsx
+++ b/web/sections/PageBuilderPanel.tsx
@@ -6,7 +6,7 @@ import { SitePreview, useDevPort } from '../SitePreview';
import { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
-import { FreeTextDialog } from './FreeTextDialog';
+import { FreeTextEditor } from './FreeTextEditor';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { pageFromParam, usePages, type Page } from '../usePages';
@@ -96,9 +96,19 @@ export const PageBuilderPanel = () => {
a new function: its effect runs the previous paint's cleanup and then paints nothing. That is what
takes the chrome away, with no reload and no second code path.
*/
+ /*
+ The frame's own document, kept between paints.
+
+ It is how a draft reaches the page: the editor writes each change into the component's node rather
+ than waiting for a save and a reload. Held in a ref because it is not state — nothing re-renders
+ when the document changes, and the effect below reads whatever is current.
+ */
+ const frameDoc = useRef(null);
+
const onDocument = useCallback(
(frameDocument: Document) => {
if (mode === 'view') return;
+ frameDoc.current = frameDocument;
return paintCanvas(frameDocument, {
sections,
onAddSection: () => handlersRef.current.add(),
@@ -240,8 +250,32 @@ export const PageBuilderPanel = () => {
setEditing({ id: component.id, sectionId: owner.id, column: component.column, html });
};
- const saveText = async (html: string) => {
+ /*
+ The draft, written into the component's node as it is typed.
+
+ The node's own children are replaced and the injected tools put back — those are the admin's and
+ live inside the component, so an innerHTML that forgot them would take the Edit button away
+ mid-edit.
+
+ Not sanitised here. The server cleans on write, so what is on screen while typing can be marginally
+ more permissive than what is stored — and the difference is visible the moment it is saved, which
+ is the right place to find out.
+ */
+ useEffect(() => {
+ const doc = frameDoc.current;
+ if (!doc || !editing?.id) return;
+
+ const node = doc.querySelector(`[data-fofos-component="${editing.id}"]`);
+ if (!node) return;
+
+ const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
+ node.innerHTML = editing.html;
+ if (tools) node.append(tools);
+ }, [editing?.id, editing?.html]);
+
+ const saveText = async () => {
if (!page || !editing) return;
+ const html = editing.html;
setCreating(true);
setError(null);
const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`;
@@ -319,13 +353,23 @@ export const PageBuilderPanel = () => {
title="Choose a video"
/>
- setEditing(null)}
- onSave={(html) => void saveText(html)}
- />
+ {editing && (
+ setEditing((was) => (was ? { ...was, html } : was))}
+ /*
+ Cancelling reloads the frame rather than putting the old markup back by hand. The node has
+ been written into on every keystroke, and restoring it correctly means remembering what was
+ there before the first one — a reload is the same answer with nothing to get wrong.
+ */
+ onCancel={() => {
+ setEditing(null);
+ setRendered((n) => n + 1);
+ }}
+ onSave={() => void saveText()}
+ />
+ )}
);
};