every block is selectable, and has a Title
canEditComponent answers "is there content to type", and a heading is not content — so the blocks with no editor of their own are now selectable too, and get the heading alone. The live draft writes into [data-block] when there is one. A titled component renders as a wrapper, and writing into the wrapper would replace the heading with the paragraph as somebody typed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+7
-4
@@ -53,6 +53,8 @@ export type CanvasComponent = {
|
|||||||
id: number;
|
id: number;
|
||||||
column: number;
|
column: number;
|
||||||
kind: string;
|
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<string, unknown>`: every reader
|
One optional field per kind that the panel reads back. Not `Record<string, unknown>`: every reader
|
||||||
would then need a cast, and the point of naming them is that the compiler knows `free-text` holds
|
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
|
* 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.
|
* 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 ──
|
* ── The marker stays ──
|
||||||
*
|
*
|
||||||
* `data-fofos-component` is how the panel finds the block being edited so it can write a draft into it
|
* `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));
|
node.setAttribute('data-fofos-component', String(component.id));
|
||||||
undo.push(() => node.removeAttribute('data-fofos-component'));
|
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) => {
|
const open = (event: MouseEvent) => {
|
||||||
// The page is real, so a block can hold a link and following it would leave the editor.
|
// The page is real, so a block can hold a link and following it would leave the editor.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs">Title</label>
|
||||||
|
<Input value={draft} placeholder="No heading" onChange={(event) => setDraft(event.currentTarget.value)} />
|
||||||
|
{saved && <p className="text-xs text-success">Saved</p>}
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@ import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
|||||||
import { FreeTextEditor } from './FreeTextEditor';
|
import { FreeTextEditor } from './FreeTextEditor';
|
||||||
import { SpacingEditor, type Padding } from './SpacingEditor';
|
import { SpacingEditor, type Padding } from './SpacingEditor';
|
||||||
import { BandSettings } from './BandSettings';
|
import { BandSettings } from './BandSettings';
|
||||||
|
import { BlockSettings } from './BlockSettings';
|
||||||
import { useEditorSlot } from '../useEditorSlot';
|
import { useEditorSlot } from '../useEditorSlot';
|
||||||
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaPicker } from '../MediaPicker';
|
||||||
@@ -462,7 +463,14 @@ export const PageBuilderPanel = () => {
|
|||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
|
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<HTMLElement>('[data-block]') ?? node).innerHTML = editing.html;
|
||||||
if (tools) node.append(tools);
|
if (tools) node.append(tools);
|
||||||
}, [editing?.id, editing?.html]);
|
}, [editing?.id, editing?.html]);
|
||||||
|
|
||||||
@@ -577,6 +585,42 @@ export const PageBuilderPanel = () => {
|
|||||||
return;
|
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(
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<BlockSettings
|
||||||
|
pageId={page.id}
|
||||||
|
sectionId={owner.id}
|
||||||
|
block={block}
|
||||||
|
onSaved={() => setRendered((n) => n + 1)}
|
||||||
|
/>
|
||||||
|
{editing && (
|
||||||
|
<FreeTextEditor
|
||||||
|
html={editing.html}
|
||||||
|
busy={creating}
|
||||||
|
onChange={(html) => setEditing((was) => (was ? { ...was, html } : was))}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setRendered((n) => n + 1);
|
||||||
|
}}
|
||||||
|
onSave={() => void saveTextRef.current()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
setEditorRef.current(
|
setEditorRef.current(
|
||||||
<FreeTextEditor
|
<FreeTextEditor
|
||||||
|
|||||||
Reference in New Issue
Block a user