The bar offered Edit and View, and View existed to see the page without the builder's outlines and handles on top of it. It was the DEFAULT, which put a click in front of every arrival at the one panel whose job is editing — and it put a branch in every painter that did nothing but wait for that click. So the mode is gone rather than defaulted the other way: no toggle, no `?mode=`, no guard. The clean page is still one click away and always was — it is the site, on its own URL. What the admin owes is the editing surface, always. The left group in the bar stays, empty. The middle one is centred against the BAR rather than against its neighbours, so removing the left group would let Desktop/Tablet/Mobile drift as the right group changes width. The preview's caption stays too. It reads as a duplicate of the device buttons and is not: they say the width, and it says which SERVER — development, not production — which nothing else on the screen does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
567 lines
23 KiB
TypeScript
567 lines
23 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { SitePreview, useDevPort } from '../SitePreview';
|
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
|
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
|
|
|
|
/*
|
|
What the Spacing dialog calls a block, by kind.
|
|
|
|
A map rather than a chain of ternaries: it was two kinds and readable, then three and not. Unknown
|
|
falls back to 'Text' — a label is a heading on a dialog, and the worst a wrong one does is read oddly
|
|
for a kind nobody has finished adding.
|
|
*/
|
|
const LABELS: Record<string, string> = {
|
|
'free-text': 'Text',
|
|
media: 'Media',
|
|
gallery: 'Gallery',
|
|
'contact-form': 'Contact form',
|
|
partners: 'Partners',
|
|
};
|
|
|
|
/*
|
|
The kinds that hold nothing, named once.
|
|
|
|
Both the picker and the canvas ask the same question — one to decide whether to open an editor, the
|
|
other whether to draw an Edit button — and two lists would drift into a button that opens nothing.
|
|
*/
|
|
const PLACES_ONLY: string[] = ['contact-form', 'partners'];
|
|
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
|
import { FreeTextEditor } from './FreeTextEditor';
|
|
import { SpacingEditor, type Padding } from './SpacingEditor';
|
|
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
|
|
import { MediaPicker } from '../MediaPicker';
|
|
import { GalleryPicker } from '../GalleryPicker';
|
|
import { pageFromParam, usePages, type Page } from '../usePages';
|
|
|
|
/**
|
|
* One page, being built.
|
|
*
|
|
* ── Why this is a different panel from Pages ──
|
|
*
|
|
* Pages is the LIST: what exists, what it is called, where it lives, in what order. This is one PAGE:
|
|
* what is on it. They are different jobs and the nav already says so — Pages is a section and the pages
|
|
* are rows underneath it — so the detail panel follows the same shape rather than showing a list with
|
|
* one row highlighted.
|
|
*
|
|
* ── It renders the real page ──
|
|
*
|
|
* The preview is the site's own page at its own address, through the platform's dev proxy. Whatever
|
|
* the blocks end up being, this is what they will be judged against, and it is already true today.
|
|
*/
|
|
export const PageBuilderPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [params] = useSearchParams();
|
|
const slug = params.get('page');
|
|
|
|
const devPort = useDevPort();
|
|
const { pages, announce } = usePages();
|
|
|
|
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
|
|
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
|
|
usePreviewable();
|
|
const { device } = useViewMode();
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [sections, setSections] = useState<CanvasSection[]>([]);
|
|
|
|
/*
|
|
The handlers, through a ref; the DATA, as a dependency.
|
|
|
|
`onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being
|
|
drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render,
|
|
so putting them in the dependency list would repaint on every render; through a ref they are always
|
|
the current ones without being part of the identity.
|
|
*/
|
|
const handlersRef = useRef<{
|
|
add: () => void;
|
|
remove: (id: number) => void;
|
|
addComponent: (section: CanvasSection, column: number) => void;
|
|
editComponent: (component: CanvasComponent) => void;
|
|
removeComponent: (component: CanvasComponent) => void;
|
|
spaceSection: (section: CanvasSection) => void;
|
|
spaceComponent: (component: CanvasComponent) => void;
|
|
}>({
|
|
add: () => {},
|
|
remove: () => {},
|
|
addComponent: () => {},
|
|
editComponent: () => {},
|
|
removeComponent: () => {},
|
|
spaceSection: () => {},
|
|
spaceComponent: () => {},
|
|
});
|
|
const [adding, setAdding] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
/** Where a component is about to be added, while the kind is being chosen. */
|
|
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
|
|
/** The component being written, and where a new one would go. Null when the dialog is shut. */
|
|
/*
|
|
Which gallery a block shows, while it is being chosen.
|
|
|
|
The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so
|
|
there is nothing to draft and nothing to cancel out of afterwards. `galleryId` rides along so
|
|
reopening the picker can show where the block already stands.
|
|
*/
|
|
const [choosingGallery, setChoosingGallery] = useState<{
|
|
id: number | null;
|
|
sectionId: number;
|
|
column: number;
|
|
galleryId: number | null;
|
|
} | null>(null);
|
|
|
|
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
|
|
null,
|
|
);
|
|
/** Choosing the file for a video — new when `id` is null, otherwise replacing an existing one. */
|
|
const [picking, setPicking] = useState<{ id: number | null; sectionId: number; column: number } | null>(null);
|
|
/** Whose spacing is being changed. A section or one component, never both. */
|
|
const [spacing, setSpacing] = useState<
|
|
{ kind: 'section' | 'component'; id: number; sectionId: number; label: string; padding: Padding } | null
|
|
>(null);
|
|
/** The scale the API publishes, so this file holds no copy of it. */
|
|
const [steps, setSteps] = useState<number[]>([]);
|
|
/*
|
|
Bumped after a write, and passed to SitePreview to remount the frame.
|
|
|
|
The page is rendered by the SITE, from the database, so a new section only appears when the document
|
|
is fetched again. Nothing else can show it: the admin does not know how to draw a section, and if it
|
|
did it would be drawing a second implementation of the page.
|
|
*/
|
|
const [rendered, setRendered] = useState(0);
|
|
|
|
/*
|
|
The builder's chrome, painted into the rendered page.
|
|
|
|
Stable, because SitePreview holds it and re-runs it on every load. A new function each render would
|
|
be a new injection each render.
|
|
*/
|
|
/*
|
|
In VIEW, nothing is painted at all.
|
|
|
|
Not the chrome dimmed or the buttons hidden — no injection, so the frame holds exactly the document
|
|
the site served. That is what View has to mean here: outlines, labels and a drop zone are the
|
|
editor's, and a preview that keeps any of them is not showing you the page.
|
|
|
|
The check is INSIDE the callback and `mode` is a dependency, so switching to View hands SitePreview
|
|
a new function: its effect runs the previous paint's cleanup and then paints nothing. That is what
|
|
takes the chrome away, with no reload and no second code path.
|
|
*/
|
|
/*
|
|
The frame's own document, kept between paints.
|
|
|
|
It is how a draft reaches the page: the editor writes each change into the component's node rather
|
|
than waiting for a save and a reload. Held in a ref because it is not state — nothing re-renders
|
|
when the document changes, and the effect below reads whatever is current.
|
|
*/
|
|
const frameDoc = useRef<Document | null>(null);
|
|
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => {
|
|
frameDoc.current = frameDocument;
|
|
return paintCanvas(frameDocument, {
|
|
sections,
|
|
onAddSection: () => handlersRef.current.add(),
|
|
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
|
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
|
|
onEditComponent: (component) => handlersRef.current.editComponent(component),
|
|
// Nothing to edit, so no button. See PLACES_ONLY.
|
|
canEditComponent: (component) => !PLACES_ONLY.includes(component.kind),
|
|
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
|
|
onSpaceSection: (section) => handlersRef.current.spaceSection(section),
|
|
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
|
|
});
|
|
},
|
|
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
|
|
// paint's cleanup and then paints nothing. That is what removes the chrome without a reload.
|
|
[sections],
|
|
);
|
|
|
|
/*
|
|
The handlers, through a ref; the DATA, as a dependency.
|
|
|
|
`onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being
|
|
drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render,
|
|
so putting them in the dependency list would repaint on every render; through a ref they are always
|
|
the current ones without being part of the identity.
|
|
*/
|
|
const deleteSection = async (sectionId: number) => {
|
|
if (!page) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${sectionId}`);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const createSection = async (values: { name: string; layout: SectionLayout }) => {
|
|
if (!page) return;
|
|
setCreating(true);
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections`, values);
|
|
setAdding(false);
|
|
// The site has to re-render for the new band to exist. See `rendered` above.
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
/*
|
|
The list, if nobody has announced it yet.
|
|
|
|
Landing here directly — a bookmark, a reload, a URL typed with `?page=4` — means Pages has never
|
|
rendered and the channel is empty. So this fetches once and announces, which also fills the nav.
|
|
Arriving from the nav, `pages` is already there and nothing is requested.
|
|
*/
|
|
useEffect(() => {
|
|
if (pages) return;
|
|
void clientRef.current
|
|
.get<{ data: Page[] }>('/fofos-admin/pages')
|
|
.then((response) => announce(response.data))
|
|
.catch((failure: unknown) => setError(readableError(failure)));
|
|
}, [pages, announce]);
|
|
|
|
const page = pageFromParam(pages, slug);
|
|
|
|
/*
|
|
The page's sections, which the canvas needs to name and outline what it finds in the document.
|
|
|
|
Fetched here rather than read out of the DOM. The markup says which id a band came from and nothing
|
|
else — no name, no layout — and inferring the layout from the classes would be the admin reading the
|
|
site's stylesheet, which is exactly the coupling all of this avoids.
|
|
*/
|
|
useEffect(() => {
|
|
if (!page) return;
|
|
void clientRef.current
|
|
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
|
.then((response) => {
|
|
setSections(response.data);
|
|
if (response.steps) setSteps(response.steps);
|
|
})
|
|
.catch((failure: unknown) => setError(readableError(failure)));
|
|
}, [page, rendered]);
|
|
|
|
// Assigned on every render, after both functions above exist, so the ref always holds the current
|
|
// pair without either being part of `onDocument`'s identity.
|
|
/*
|
|
Add component asks WHAT, even though only one kind can be chosen today.
|
|
|
|
It opened the text editor straight away, on the reasoning that one option is not a choice. That
|
|
optimised the state of the app rather than its shape: somebody adding a component could not see what
|
|
a component is or that there will be others, and the day the second kind lands the interaction
|
|
changes underneath them. The picker names the ones that are coming, the way the nav does.
|
|
*/
|
|
const addComponent = (section: CanvasSection, column: number) =>
|
|
setChoosing({ sectionId: section.id, column });
|
|
|
|
/*
|
|
A block that holds nothing is CREATED on the spot.
|
|
|
|
No dialog, because there is nothing to fill in: the contact form is the site's form and the partner
|
|
row is the partner table. Asking for a confirmation with no field on it would be a step that exists
|
|
only to be got past — the same argument the picker itself answers the other way, where there IS a
|
|
choice to make.
|
|
*/
|
|
const createEmpty = async (kind: ComponentKind, at: { sectionId: number; column: number }) => {
|
|
if (!page) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections/${at.sectionId}/components`, {
|
|
kind,
|
|
column: at.column,
|
|
content: {},
|
|
});
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const chooseKind = (kind: ComponentKind) => {
|
|
if (!choosing) return;
|
|
const at = choosing;
|
|
setChoosing(null);
|
|
// Each kind opens its own editor. A kind with no case here cannot be created, which is the point.
|
|
if (kind === 'free-text') setEditing({ id: null, ...at, html: '' });
|
|
if (kind === 'media') setPicking({ id: null, ...at });
|
|
if (kind === 'gallery') setChoosingGallery({ id: null, ...at, galleryId: null });
|
|
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
|
|
};
|
|
|
|
/*
|
|
A media block is a PATH, saved as soon as it is chosen.
|
|
|
|
Free text needs a dialog because writing is a thing people do halfway; picking a file is one act
|
|
with one outcome, so there is nothing to draft and nothing to cancel out of afterwards.
|
|
|
|
Photograph or video is not asked. The file already knows, and the site reads it off the extension
|
|
the way a gallery item always has — a kind stored here would be a second answer able to disagree.
|
|
*/
|
|
const saveVideo = async (url: string) => {
|
|
if (!page || !picking) return;
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${picking.sectionId}/components`;
|
|
setPicking(null);
|
|
setError(null);
|
|
try {
|
|
if (picking.id === null) {
|
|
await clientRef.current.post(base, { kind: 'media', column: picking.column, content: { url } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${picking.id}`, { content: { url } });
|
|
}
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
/*
|
|
A gallery block is a REFERENCE, saved as soon as the set is chosen — see GalleryPicker.
|
|
|
|
Identical in shape to saveVideo because it is the same interaction: pick one thing, store one
|
|
value. The two are left as two functions rather than one generic saver, because what they store
|
|
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
|
|
*/
|
|
const saveGallery = async (galleryId: number) => {
|
|
if (!page || !choosingGallery) return;
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${choosingGallery.sectionId}/components`;
|
|
const target = choosingGallery;
|
|
setChoosingGallery(null);
|
|
setError(null);
|
|
try {
|
|
if (target.id === null) {
|
|
await clientRef.current.post(base, { kind: 'gallery', column: target.column, content: { galleryId } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${target.id}`, { content: { galleryId } });
|
|
}
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const editComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
|
|
// Edit means whatever editing THIS kind means. The canvas draws one button; which door it opens is
|
|
// decided here, where the kinds are known.
|
|
// `video` too — no row holds it since the rename, but one written by something older should
|
|
// still be editable rather than open nothing.
|
|
if (component.kind === 'media' || component.kind === 'video') {
|
|
setPicking({ id: component.id, sectionId: owner.id, column: component.column });
|
|
return;
|
|
}
|
|
|
|
if (component.kind === 'gallery') {
|
|
const held = owner.components.find((one) => one.id === component.id)?.content?.galleryId;
|
|
setChoosingGallery({
|
|
id: component.id,
|
|
sectionId: owner.id,
|
|
column: component.column,
|
|
galleryId: typeof held === 'number' ? held : null,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const stored = owner.components.find((one) => one.id === component.id);
|
|
const html = typeof stored?.content?.html === 'string' ? stored.content.html : '';
|
|
setEditing({ id: component.id, sectionId: owner.id, column: component.column, html });
|
|
};
|
|
|
|
/*
|
|
The draft, written into the component's node as it is typed.
|
|
|
|
The node's own children are replaced and the injected tools put back — those are the admin's and
|
|
live inside the component, so an innerHTML that forgot them would take the Edit button away
|
|
mid-edit.
|
|
|
|
Not sanitised here. The server cleans on write, so what is on screen while typing can be marginally
|
|
more permissive than what is stored — and the difference is visible the moment it is saved, which
|
|
is the right place to find out.
|
|
*/
|
|
useEffect(() => {
|
|
const doc = frameDoc.current;
|
|
if (!doc || !editing?.id) return;
|
|
|
|
const node = doc.querySelector<HTMLElement>(`[data-fofos-component="${editing.id}"]`);
|
|
if (!node) return;
|
|
|
|
const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
|
|
node.innerHTML = editing.html;
|
|
if (tools) node.append(tools);
|
|
}, [editing?.id, editing?.html]);
|
|
|
|
const saveText = async () => {
|
|
if (!page || !editing) return;
|
|
const html = editing.html;
|
|
setCreating(true);
|
|
setError(null);
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`;
|
|
try {
|
|
if (editing.id === null) {
|
|
await clientRef.current.post(base, { kind: 'free-text', column: editing.column, content: { html } });
|
|
} else {
|
|
await clientRef.current.patch(`${base}/${editing.id}`, { content: { html } });
|
|
}
|
|
setEditing(null);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const deleteComponent = async (component: CanvasComponent) => {
|
|
if (!page) return;
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
setError(null);
|
|
try {
|
|
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`);
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
/*
|
|
Spacing is written IMMEDIATELY, like adding and deleting.
|
|
|
|
There is no draft to lose: a step is one choice from a short list, and the effect is on screen before
|
|
the menu closes. A Save for it would mean a second thing to remember on a panel whose other verbs
|
|
already act.
|
|
*/
|
|
const saveSpacing = async (padding: Padding) => {
|
|
if (!page || !spacing) return;
|
|
setSpacing({ ...spacing, padding });
|
|
const base = `/fofos-admin/pages/${page.id}/sections/${spacing.sectionId}`;
|
|
const url = spacing.kind === 'section' ? base : `${base}/components/${spacing.id}`;
|
|
try {
|
|
await clientRef.current.patch(url, { padding });
|
|
setRendered((n) => n + 1);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
};
|
|
|
|
const spaceSection = (section: CanvasSection) =>
|
|
setSpacing({
|
|
kind: 'section',
|
|
id: section.id,
|
|
sectionId: section.id,
|
|
label: section.name,
|
|
padding: (section.padding ?? {}) as Padding,
|
|
});
|
|
|
|
const spaceComponent = (component: CanvasComponent) => {
|
|
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
|
|
if (!owner) return;
|
|
const stored = owner.components.find((one) => one.id === component.id);
|
|
setSpacing({
|
|
kind: 'component',
|
|
id: component.id,
|
|
sectionId: owner.id,
|
|
label: LABELS[component.kind] ?? 'Text',
|
|
padding: (stored?.padding ?? {}) as Padding,
|
|
});
|
|
};
|
|
|
|
handlersRef.current = {
|
|
add: () => setAdding(true),
|
|
remove: (sectionId: number) => void deleteSection(sectionId),
|
|
addComponent,
|
|
editComponent,
|
|
removeComponent: (component) => void deleteComponent(component),
|
|
spaceSection,
|
|
spaceComponent,
|
|
};
|
|
|
|
if (error) return <div className="p-4 text-xs text-destructive">{error}</div>;
|
|
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
|
|
|
|
if (!page) {
|
|
// A page that was deleted in another panel, or an id somebody typed. Said plainly rather than
|
|
// redirected: a silent bounce to the list leaves you wondering whether you clicked the wrong thing.
|
|
return <div className="p-4 text-xs text-muted-foreground">That page no longer exists.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-3 p-4">
|
|
<div className="flex shrink-0 items-baseline gap-2">
|
|
<h3 className="text-sm font-medium">{page.title}</h3>
|
|
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
|
</div>
|
|
|
|
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path`
|
|
has no leading slash — Home's is the empty string, which the proxy resolves to the root. */}
|
|
{/* `reloadToken` moves on every write, which refetches the document AND re-runs `onDocument` —
|
|
so the decoration is redrawn against the sections that now exist. */}
|
|
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
|
|
|
|
<AddSectionDialog
|
|
open={adding}
|
|
busy={creating}
|
|
onCancel={() => setAdding(false)}
|
|
onCreate={(values) => void createSection(values)}
|
|
/>
|
|
|
|
<AddComponentDialog open={choosing !== null} onCancel={() => setChoosing(null)} onChoose={chooseKind} />
|
|
|
|
<MediaPicker
|
|
open={picking !== null}
|
|
onClose={() => setPicking(null)}
|
|
onPick={(path) => void saveVideo(path)}
|
|
title="Choose a photograph or a video"
|
|
/>
|
|
|
|
<GalleryPicker
|
|
open={choosingGallery !== null}
|
|
onClose={() => setChoosingGallery(null)}
|
|
onPick={(galleryId) => void saveGallery(galleryId)}
|
|
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>
|
|
);
|
|
};
|