diff --git a/web/EditorPanel.tsx b/web/EditorPanel.tsx
new file mode 100644
index 0000000..04afdae
--- /dev/null
+++ b/web/EditorPanel.tsx
@@ -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 (
+
+ {/* 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. */}
+
+ Pick a block in the page to edit it, or press Spacing on a band.
+
+
+ );
+ }
+
+ return {editor}
;
+};
diff --git a/web/layout.ts b/web/layout.ts
index 792b5be..9f53320 100644
--- a/web/layout.ts
+++ b/web/layout.ts
@@ -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 },
],
};
diff --git a/web/panels.ts b/web/panels.ts
index 7c4f255..f3f8ba4 100644
--- a/web/panels.ts
+++ b/web/panels.ts
@@ -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,
+ },
];
diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx
index 3105cf0..6c7fef6 100644
--- a/web/sections/PageBuilderPanel.tsx
+++ b/web/sections/PageBuilderPanel.tsx
@@ -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(
+ void saveSpacingRef.current(padding)}
+ onClose={() => setSpacing(null)}
+ />,
+ );
+ return;
+ }
+
+ if (editing) {
+ setEditorRef.current(
+ 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 && (
- void saveSpacing(padding)}
- onClose={() => setSpacing(null)}
- />
- )}
-
- {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()}
- />
- )}
);
};
diff --git a/web/useEditorSlot.ts b/web/useEditorSlot.ts
new file mode 100644
index 0000000..9192b08
--- /dev/null
+++ b/web/useEditorSlot.ts
@@ -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(KEY, null);