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:
@@ -0,0 +1,30 @@
|
||||
import { useEditorSlot } from './useEditorSlot';
|
||||
|
||||
/**
|
||||
* Where an editor opens — a column beside the preview rather than a drawer beneath it.
|
||||
*
|
||||
* It was rendered under the frame, which took the height from the thing being edited: choosing a
|
||||
* spacing shrank the page you were choosing it for, and on a laptop the preview lost a third of itself
|
||||
* to a row of two dropdowns. A panel keeps the page whole and puts the controls where a workspace puts
|
||||
* controls.
|
||||
*
|
||||
* It holds nothing. The panel that owns a selection builds the editor and announces it — see
|
||||
* useEditorSlot for why it carries the element rather than a description of one.
|
||||
*/
|
||||
export const EditorPanel = () => {
|
||||
const [editor] = useEditorSlot();
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center p-6 text-center">
|
||||
{/* Names what the panel is FOR rather than saying it is empty. "Nothing selected" describes
|
||||
this component's state; this describes the reader's next move. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a block in the page to edit it, or press Spacing on a band.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="size-full overflow-auto p-3">{editor}</div>;
|
||||
};
|
||||
+8
-2
@@ -9,13 +9,19 @@ import type { LayoutNode } from 'officerdev';
|
||||
* better had in the chat screen. Worth adding on the day this plugin ships a `skills/` directory.
|
||||
*
|
||||
* The nav is the narrow one: nine rows of two short lines, and nothing in it grows.
|
||||
*
|
||||
* The editor is a COLUMN and not a drawer under the preview, which is where it used to be. A drawer
|
||||
* takes its 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. The preview stays whole and the
|
||||
* controls sit where a workspace puts controls.
|
||||
*/
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'fofos-admin-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'fofos-admin-nav', appType: 'fofos-admin-nav' }, size: 28 },
|
||||
{ node: { type: 'panel', id: 'fofos-admin-detail', appType: 'fofos-admin-detail' }, size: 72 },
|
||||
{ node: { type: 'panel', id: 'fofos-admin-nav', appType: 'fofos-admin-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'fofos-admin-detail', appType: 'fofos-admin-detail' }, size: 54 },
|
||||
{ node: { type: 'panel', id: 'fofos-admin-editor', appType: 'fofos-admin-editor' }, size: 24 },
|
||||
],
|
||||
};
|
||||
|
||||
+10
-2
@@ -1,11 +1,12 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { List, PanelRight } from 'lucide-react';
|
||||
import { List, PanelRight, SlidersHorizontal } from 'lucide-react';
|
||||
import { NavPanel } from './NavPanel';
|
||||
import { DetailPanel } from './DetailPanel';
|
||||
import { DetailHeader } from './DetailHeader';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
|
||||
/**
|
||||
* Two panels: a nav, and whatever it selects.
|
||||
* Three panels: a nav, whatever it selects, and where an editor opens.
|
||||
*
|
||||
* They are SIBLINGS that never speak to each other — the nav writes `?section=` and the detail panel
|
||||
* reads it. That is `docs/navigation-audit.md`'s rule, and it is what makes every section linkable and
|
||||
@@ -32,4 +33,11 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
header: DetailHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'fofos-admin-editor',
|
||||
name: 'Editor',
|
||||
icon: SlidersHorizontal,
|
||||
component: EditorPanel,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
/**
|
||||
* What the editor panel is showing, announced by whichever panel owns it.
|
||||
*
|
||||
* ── Why the ELEMENT and not a descriptor ──
|
||||
*
|
||||
* The obvious shape is `{ kind: 'spacing', componentId: 12 }` and a switch in the panel. That means
|
||||
* every editor's loading, saving and cancelling moves out of the panel that knows about sections and
|
||||
* into one that would have to learn — a second place that understands what a component is, kept in
|
||||
* step by hand.
|
||||
*
|
||||
* So the owner builds the editor and this carries it. The panel is a frame: it decides where the
|
||||
* editor lives on screen and nothing about what it does. `SaveTarget` makes the same trade for the
|
||||
* same reason — the section announces its own Save rather than DetailPanel learning to perform one.
|
||||
*
|
||||
* ── The hazard, which is real ──
|
||||
*
|
||||
* An element captures the props it was built with. Announce it once and the panel will happily show
|
||||
* the values from that render forever. Whoever announces must do so in an effect that depends on
|
||||
* everything the editor displays — the same rule `useSaveTarget` documents, and the same way to get it
|
||||
* wrong.
|
||||
*
|
||||
* ── `useGlobal`, not a context ──
|
||||
*
|
||||
* The two panels are siblings; neither may own the other, and wrapping both in a provider would mean a
|
||||
* component above them knowing they exist. A keyed slot is the house pattern for exactly this.
|
||||
*/
|
||||
const KEY = ['fofos-admin', 'editor'];
|
||||
|
||||
export const useEditorSlot = () => useGlobal<ReactNode>(KEY, null);
|
||||
Reference in New Issue
Block a user