diff --git a/web/canvas.ts b/web/canvas.ts index 5f0335b..16c6cd3 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -53,6 +53,8 @@ export type CanvasComponent = { id: number; column: number; kind: string; + /** The heading the page draws above the block, or null. Every kind may have one. */ + title?: string | null; /* One optional field per kind that the panel reads back. Not `Record`: every reader would then need a cast, and the point of naming them is that the compiler knows `free-text` holds @@ -128,6 +130,11 @@ const style = (element: HTMLElement, rules: string[]) => { * So this draws nothing at all: no elements, no stylesheet, no outlines. A click selects a block, and * its editor opens in the panel beside the preview — where every other control now lives. * + * ── Every block is selectable ── + * + * Including the ones with no editor of their own. `canEditComponent` answers "is there content to + * type", and since every block now has an optional heading, there is always something to open. + * * ── The marker stays ── * * `data-fofos-component` is how the panel finds the block being edited so it can write a draft into it @@ -158,10 +165,6 @@ export function paintSelection(document: Document, canvas: Canvas): () => void { node.setAttribute('data-fofos-component', String(component.id)); undo.push(() => node.removeAttribute('data-fofos-component')); - // A block the panel has no editor for is left alone: a click that opens nothing reads as a - // failure, which is worse than a click that does nothing. See canEditComponent. - if (canvas.canEditComponent && !canvas.canEditComponent(component)) continue; - const open = (event: MouseEvent) => { // The page is real, so a block can hold a link and following it would leave the editor. event.preventDefault(); diff --git a/web/sections/BlockSettings.tsx b/web/sections/BlockSettings.tsx new file mode 100644 index 0000000..eb485b0 --- /dev/null +++ b/web/sections/BlockSettings.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef, useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { useClient } from 'hooks/useClient'; +import { readableError } from '../errors'; +import { useAutosave } from '../useAutosave'; +import { useFlash } from '../useFlash'; + +/** + * A block's own heading. Optional, and above whatever editor the block has. + * + * ── Why it is not inside the free text ── + * + * Typing a heading into the text works for one kind of block and no others. A gallery, a video and a + * partner row all want to announce themselves and have nowhere to put the words — so the heading is a + * property of the BLOCK rather than of its contents, and every kind gets one. + * + * That is also why this is announced for every selected block, including the ones with no editor of + * their own: `canEditComponent` answers "is there content to type", and a heading is not content. + */ +export const BlockSettings = ({ + pageId, + sectionId, + block, + onSaved, +}: { + pageId: number; + sectionId: number; + block: { id: number; title?: string | null }; + onSaved: () => void; +}) => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [draft, setDraft] = useState(block.title ?? ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, flashSaved] = useFlash(); + + // Keyed on the id, so re-reading the same block after a save does not throw away what is being typed. + useEffect(() => setDraft(block.title ?? ''), [block.id]); + + const save = async () => { + setBusy(true); + setError(null); + try { + // Empty is sent as an empty string, which the API reads as "take the heading off". + await clientRef.current.patch( + `/fofos-admin/pages/${pageId}/sections/${sectionId}/components/${block.id}`, + { title: draft }, + ); + flashSaved(); + onSaved(); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(false); + } + }; + + useAutosave(draft !== (block.title ?? '') ? draft : null, busy, save); + + return ( +
+ + setDraft(event.currentTarget.value)} /> + {saved &&

Saved

} + {error &&

{error}

} +
+ ); +}; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index ad6eaa1..a4aa75c 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -33,6 +33,7 @@ import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; import { FreeTextEditor } from './FreeTextEditor'; import { SpacingEditor, type Padding } from './SpacingEditor'; import { BandSettings } from './BandSettings'; +import { BlockSettings } from './BlockSettings'; import { useEditorSlot } from '../useEditorSlot'; import { AddComponentDialog, type ComponentKind } from './AddComponentDialog'; import { MediaPicker } from '../MediaPicker'; @@ -462,7 +463,14 @@ export const PageBuilderPanel = () => { if (!node) return; const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]'); - node.innerHTML = editing.html; + /* + The BLOCK inside, when the component has a heading. + + A titled component renders as a wrapper — heading, then `[data-block]` — and writing into the + wrapper would replace the heading with the paragraph as somebody typed. Untitled ones are not + wrapped, so the node itself is the block. + */ + (node.querySelector('[data-block]') ?? node).innerHTML = editing.html; if (tools) node.append(tools); }, [editing?.id, editing?.html]); @@ -577,6 +585,42 @@ export const PageBuilderPanel = () => { return; } + /* + The selected block's heading, above whatever editor its kind has. + + Announced together, because they are one selection: the heading belongs to the block and the + editor is what is inside it. A block with no editor gets the heading alone, which is why every + block is selectable now. + */ + const owner = piece ? sections.find((one) => one.components.some((c) => String(c.id) === piece)) : null; + const block = owner?.components.find((c) => String(c.id) === piece); + + if (block && owner && page) { + setEditorRef.current( +
+ setRendered((n) => n + 1)} + /> + {editing && ( + setEditing((was) => (was ? { ...was, html } : was))} + onCancel={() => { + setEditing(null); + setRendered((n) => n + 1); + }} + onSave={() => void saveTextRef.current()} + /> + )} +
, + ); + return; + } + if (editing) { setEditorRef.current(