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()} + /> + )} ); };