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);