From 45104c1f98d0f294979c7c4f2892b51b7cc45a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 06:06:45 +0100 Subject: [PATCH] the text editor renders below the page, and types into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/canvas.ts | 4 +- web/sections/FreeTextDialog.tsx | 70 ------------------------------- web/sections/FreeTextEditor.tsx | 57 +++++++++++++++++++++++++ web/sections/PageBuilderPanel.tsx | 62 +++++++++++++++++++++++---- 4 files changed, 113 insertions(+), 80 deletions(-) delete mode 100644 web/sections/FreeTextDialog.tsx create mode 100644 web/sections/FreeTextEditor.tsx 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 ( - !next && onCancel()}> - - - Text - - - {/* Keyed on `initial`, so opening a DIFFERENT component rebuilds the editor rather than - reusing one still holding the last component's document. */} -
- -
- - - - {/* 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. */} - - -
-
- ); -}; 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()} + /> + )} ); };