an editor opens beside the page, not under it

The spacing and free-text editors rendered below the preview, so they took their height from the thing
being edited: choosing a spacing shrank the page you were choosing it for, and on a laptop two
dropdowns cost a third of the frame.

They are a third panel now, to the right of the preview. The workspace already knows how to size and
move panels; a drawer was a second, worse layout system living inside one of them.

The panel holds nothing. Whichever panel owns a selection BUILDS the editor and announces it through a
`useGlobal` slot — the two are siblings and neither may own the other, and a provider around both would
mean something above them knowing they exist.

The slot carries the ELEMENT, not a description of one. A descriptor plus a switch would move every
editor's loading, saving and cancelling into a panel that would have to learn what a section is — a
second place that understands the page builder, kept in step by hand. SaveTarget makes the same trade
for the same reason.

The hazard is real and worth naming: an element captures the props it was built with, so the announcing
effect must depend on everything the editor DISPLAYS. The save handlers sit in refs so they cannot drag
that list wider — depending on them would re-announce on every keystroke, and omitting them without
refs would announce a stale one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 02:29:08 +01:00
co-authored by Claude Opus 5
parent fa8917aa75
commit 9eb17c8d03
5 changed files with 143 additions and 33 deletions
+63 -29
View File
@@ -31,6 +31,7 @@ const PLACES_ONLY: string[] = ['contact-form', 'partners'];
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { useEditorSlot } from '../useEditorSlot';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { GalleryPicker } from '../GalleryPicker';
@@ -477,6 +478,68 @@ export const PageBuilderPanel = () => {
});
};
const [, setEditor] = useEditorSlot();
const setEditorRef = useRef(setEditor);
setEditorRef.current = setEditor;
const saveSpacingRef = useRef(saveSpacing);
saveSpacingRef.current = saveSpacing;
const saveTextRef = useRef(saveText);
saveTextRef.current = saveText;
/*
Hand the open editor to the editor panel.
Announced rather than rendered, because the panel is a SIBLING — see useEditorSlot. The dependency
list is everything either editor DISPLAYS, which is the part that is easy to get wrong: an element
captures the props it was built with, so one left out here shows up as a control that has quietly
stopped responding rather than as an error.
The handlers are in refs for that reason. They are rebuilt every render, and depending on them
would re-announce on every keystroke; leaving them out without refs would announce a stale one.
*/
useEffect(() => {
if (spacing) {
setEditorRef.current(
<SpacingEditor
label={spacing.label}
padding={spacing.padding}
device={device}
steps={steps}
busy={false}
onChange={(padding) => void saveSpacingRef.current(padding)}
onClose={() => setSpacing(null)}
/>,
);
return;
}
if (editing) {
setEditorRef.current(
<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 saveTextRef.current()}
/>,
);
return;
}
setEditorRef.current(null);
}, [spacing, editing, creating, device, steps]);
// Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at.
useEffect(() => () => setEditorRef.current(null), []);
handlersRef.current = {
add: () => setAdding(true),
remove: (sectionId: number) => void deleteSection(sectionId),
@@ -532,35 +595,6 @@ export const PageBuilderPanel = () => {
selected={choosingGallery?.galleryId ?? null}
/>
{spacing && (
<SpacingEditor
label={spacing.label}
padding={spacing.padding}
device={device}
steps={steps}
busy={false}
onChange={(padding) => void saveSpacing(padding)}
onClose={() => setSpacing(null)}
/>
)}
{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>
);
};