the text editor renders below the page, and types into it

A modal covered the thing being edited, which for a page builder 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. It sits
under the preview now, the way the Layout section's controls already do.

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, so the loop stops being "type, save, look" and becomes looking. That is
the same door everything else here goes through — the frame is same origin — and it is what the modal
made impossible rather than merely awkward.

The node's children are replaced and the injected tools put back. Those live inside the component and
belong to the admin, so an innerHTML that forgot them would take the Edit button away mid-edit.

Not sanitised while typing. The server cleans on write, so what is on screen can be marginally more
permissive than what is stored — and the difference shows the moment it is saved, which is the right
place to find out.

Cancel reloads the frame rather than restoring the markup by hand. The node has been written into on
every keystroke, and putting it back correctly means remembering what was there before the first one; a
reload is the same answer with nothing to get wrong.

The editor is capped and scrolls, so a long piece does not push the page off the top of the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 06:06:45 +01:00
co-authored by Claude Opus 5
parent 466ad26f11
commit 45104c1f98
4 changed files with 113 additions and 80 deletions
+53 -9
View File
@@ -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<Document | null>(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<HTMLElement>(`[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"
/>
<FreeTextDialog
open={editing !== null}
busy={creating}
initial={editing?.html ?? ''}
onCancel={() => setEditing(null)}
onSave={(html) => void saveText(html)}
/>
{editing && (
<FreeTextEditor
html={editing.html}
busy={creating}
onChange={(html) => 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()}
/>
)}
</div>
);
};