the builder draws the sections it finds
An empty section renders as nothing. That is correct on the public site and useless in an editor: somebody who has just chosen "two columns" sees a blank page and has to take it on trust. So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines the columns inside it, because a one-column band and a two-column band are identical until something is in them, which is exactly when it is too late to notice the wrong choice. The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column. Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that has never heard of this file, and read as what they are. That is the whole contract — one attribute on the public site buys the entire editor. Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came from and nothing else; inferring the layout from its classes would be the admin reading the site's stylesheet, which is the coupling all of this exists to avoid. ── The race, and why the fix is not another ref ── Creating a section starts two things that do not finish in a fixed order: the frame fetches a new document, and the panel fetches the sections to draw on it. Painting only on load meant painting with whatever had arrived by then, which after a create was usually the data from before it existed. So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a dependency, and whichever arrives second triggers the paint that counts. The caller then controls repainting through its own useCallback dependencies, which is what those are for. The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and would repaint on every one; the sections are the thing being drawn and should be exactly what decides when to redraw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-17
@@ -66,6 +66,12 @@ export const SitePreview = ({
|
||||
*
|
||||
* Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the
|
||||
* document it was injected into.
|
||||
*
|
||||
* ── Give it a stable identity, and change that identity to REPAINT ──
|
||||
*
|
||||
* It is re-run whenever it changes, so a `useCallback` whose dependencies are the data being drawn is
|
||||
* exactly right: new data, new function, redrawn. An inline arrow is a new function every render and
|
||||
* would reinject on every one.
|
||||
*/
|
||||
onDocument?: (document: Document) => void | (() => void);
|
||||
/**
|
||||
@@ -167,26 +173,28 @@ export const SitePreview = ({
|
||||
}, [applyTheme, ready]);
|
||||
|
||||
/*
|
||||
Hand the loaded document to whoever asked for it.
|
||||
Hand the loaded document to whoever asked for it — on load, and again whenever they change.
|
||||
|
||||
Through a ref so a caller's inline arrow — which is a new function every render — does not re-run
|
||||
the injection on every render. Same trap as `useClient`, one level along.
|
||||
Two things have to happen and they race: the frame fetches a new document, and the caller fetches
|
||||
whatever it means to draw on it. Neither reliably finishes first. Running the injection ONLY on load
|
||||
meant painting with whatever data had arrived by then, which after creating a section was usually
|
||||
the data from before it existed.
|
||||
|
||||
So `loaded` is state rather than a callback: the effect below depends on both it and `onDocument`,
|
||||
and runs again when either moves. Whichever arrives second triggers the paint that counts.
|
||||
*/
|
||||
const onDocumentRef = useRef(onDocument);
|
||||
onDocumentRef.current = onDocument;
|
||||
const cleanupRef = useRef<(() => void) | void>(undefined);
|
||||
|
||||
const handOverDocument = useCallback(() => {
|
||||
// Whatever was injected into the PREVIOUS document goes first. A reload replaces the document, so
|
||||
// a cleanup that runs after the fact is cleaning something that no longer exists.
|
||||
cleanupRef.current?.();
|
||||
cleanupRef.current = undefined;
|
||||
const [loaded, setLoaded] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
const frameDocument = frame.current?.contentDocument;
|
||||
if (frameDocument) cleanupRef.current = onDocumentRef.current?.(frameDocument);
|
||||
}, []);
|
||||
if (!frameDocument) return;
|
||||
|
||||
useEffect(() => () => cleanupRef.current?.(), []);
|
||||
const cleanup = onDocument?.(frameDocument);
|
||||
// Runs before the next paint and on unmount, so nothing injected outlives its document — and two
|
||||
// paints never leave two sets of controls.
|
||||
return () => cleanup?.();
|
||||
}, [loaded, onDocument]);
|
||||
|
||||
const message =
|
||||
!port ? 'No development server is configured — set its port under Administration.'
|
||||
@@ -236,12 +244,17 @@ export const SitePreview = ({
|
||||
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser
|
||||
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
|
||||
key={reloadToken ?? 0}
|
||||
ref={frame}
|
||||
// A remount is a NEW document, and the effect above must not paint into the old one on
|
||||
// the strength of a `loaded` that referred to it.
|
||||
ref={(node) => {
|
||||
frame.current = node;
|
||||
if (!node) setLoaded(0);
|
||||
}}
|
||||
// Applied on load as well as on change: the document is replaced on a reload, and the
|
||||
// properties go with it.
|
||||
onLoad={() => {
|
||||
applyTheme();
|
||||
handOverDocument();
|
||||
setLoaded((n) => n + 1);
|
||||
}}
|
||||
src={`/api/dev-proxy/${port}/${path}`}
|
||||
title={`The site at /${path}`}
|
||||
|
||||
+151
-30
@@ -9,40 +9,58 @@
|
||||
*
|
||||
* It is possible because the preview frame is SAME ORIGIN — it comes through the platform's dev proxy
|
||||
* on Officer's own host — so `iframe.contentDocument` is readable and writable. That single fact is
|
||||
* what the whole page builder rests on, and it is why swapping this plugin's hand-rolled cross-origin
|
||||
* proxy for the platform's mattered beyond the bugs it fixed.
|
||||
* what the whole page builder rests on.
|
||||
*
|
||||
* ── What the SITE has to provide, and it is only this ──
|
||||
*
|
||||
* data-page-empty the placeholder shown when a page has no sections
|
||||
* data-section-id which row a band came from
|
||||
* data-column a column within a band
|
||||
*
|
||||
* Three attributes and no behaviour. Markers rather than classes, so they cannot be mistaken for
|
||||
* styling, cannot be purged by a build that has never heard of this file, and read as what they are:
|
||||
* handles for something else.
|
||||
*
|
||||
* ── Plain DOM, not React ──
|
||||
*
|
||||
* React could portal into another document, and it would mean running a reconciler against a document
|
||||
* that reloads under it whenever the dev server rebuilds. The controls here are a handful of elements
|
||||
* with click handlers; the cost of building them by hand is a few lines, and the benefit is that a
|
||||
* reload is just a document that no longer exists rather than a tree React still thinks it owns.
|
||||
* that reloads under it whenever the dev server rebuilds. These are a handful of elements with click
|
||||
* handlers; building them by hand costs a few lines, and a reload becomes a document that no longer
|
||||
* exists rather than a tree React still thinks it owns.
|
||||
*
|
||||
* ── Styles are inline, on purpose ──
|
||||
*
|
||||
* The frame has the SITE's stylesheet, which is Tailwind built by scanning the site's own templates.
|
||||
* A class this file invents was never scanned and does not exist there, so it would silently do
|
||||
* nothing. Inline styles cannot be purged by a build that has never heard of this file.
|
||||
* nothing.
|
||||
*/
|
||||
|
||||
/** Everything injected carries this, so a re-injection can find and remove what the last one left. */
|
||||
const MARK = 'data-fofos-admin-canvas';
|
||||
|
||||
/** The editor's own colour. Deliberately not the site's — this is chrome, and it should not blend in. */
|
||||
const ACCENT = '#3b82f6';
|
||||
|
||||
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two' };
|
||||
|
||||
export type Canvas = {
|
||||
/** Somebody pressed Add Section. */
|
||||
sections: CanvasSection[];
|
||||
onAddSection: () => void;
|
||||
onDeleteSection: (section: CanvasSection) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the page's content goes.
|
||||
*
|
||||
* `<main>` because it is what the site's Layout renders and what the word means — the page minus the
|
||||
* furniture around it. Not a class or a data attribute, which would be a contract the SITE has to keep,
|
||||
* and the point of this file is that the site does not have to know it exists.
|
||||
* furniture around it.
|
||||
*/
|
||||
const contentRoot = (document: Document): HTMLElement | null => document.querySelector('main');
|
||||
|
||||
const style = (element: HTMLElement, rules: string[]) => {
|
||||
element.style.cssText = rules.join(';');
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw the builder's controls into a rendered page.
|
||||
*
|
||||
@@ -54,58 +72,161 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
||||
if (!root) return () => {};
|
||||
|
||||
// Anything a previous injection left. Cheap insurance: the cleanup should have run, and if a reload
|
||||
// raced it, a second Add Section button is exactly the sort of thing nobody reports as a bug.
|
||||
// raced it, a second Add section button is exactly the sort of thing nobody reports as a bug.
|
||||
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
||||
|
||||
/** Everything to undo, in the order it was done. */
|
||||
const undo: (() => void)[] = [];
|
||||
|
||||
/*
|
||||
The site's EMPTY-PAGE placeholder, hidden while the builder draws its own controls in that space.
|
||||
The site's empty-page placeholder, hidden while the builder draws its own controls in that space.
|
||||
|
||||
Found by its marker rather than by position. It was `firstElementChild`, which was right for exactly
|
||||
as long as an empty page was the only page — the moment a page has a section, the first child IS
|
||||
that section and hiding it would hide the thing being edited.
|
||||
|
||||
Hidden rather than removed, so the cleanup can simply put it back. A page whose last section is
|
||||
deleted then shows it again without a reload.
|
||||
Found by its marker rather than by position. Hidden rather than removed, so the cleanup puts it
|
||||
back — a page whose last section is deleted shows it again without a reload.
|
||||
*/
|
||||
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
undo.push(() => {
|
||||
placeholder.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Every section, outlined and named.
|
||||
|
||||
This is the whole reason the decoration exists: an EMPTY section renders as nothing, which is
|
||||
correct on the public site and useless in an editor. Somebody who has just chosen "two columns"
|
||||
needs to see two columns, not a blank page they have to take on trust.
|
||||
*/
|
||||
for (const section of canvas.sections) {
|
||||
const element = root.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
|
||||
if (!element) continue;
|
||||
|
||||
const previousPosition = element.style.position;
|
||||
const previousOutline = element.style.outline;
|
||||
const previousOffset = element.style.outlineOffset;
|
||||
const previousMargin = element.style.margin;
|
||||
|
||||
// `relative` so the label and the controls can be placed against this band rather than the page.
|
||||
element.style.position = 'relative';
|
||||
element.style.outline = `1px solid ${ACCENT}`;
|
||||
element.style.outlineOffset = '4px';
|
||||
// Room for the outline and the label above it, so two bands in a row do not touch.
|
||||
element.style.margin = '2.5rem 0';
|
||||
|
||||
undo.push(() => {
|
||||
element.style.position = previousPosition;
|
||||
element.style.outline = previousOutline;
|
||||
element.style.outlineOffset = previousOffset;
|
||||
element.style.margin = previousMargin;
|
||||
});
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.setAttribute(MARK, 'section-bar');
|
||||
style(bar, [
|
||||
'position:absolute',
|
||||
'top:-1.75rem',
|
||||
'left:0',
|
||||
'right:0',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'gap:0.5rem',
|
||||
'font-size:0.6875rem',
|
||||
`color:${ACCENT}`,
|
||||
// The page's own font would make this look like content. It is not content.
|
||||
'font-family:ui-sans-serif, system-ui, sans-serif',
|
||||
]);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = section.name;
|
||||
style(label, ['font-weight:600']);
|
||||
|
||||
const shape = document.createElement('span');
|
||||
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
|
||||
style(shape, ['opacity:0.7']);
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Delete';
|
||||
style(remove, [
|
||||
'margin-left:auto',
|
||||
'cursor:pointer',
|
||||
'background:none',
|
||||
'border:0',
|
||||
'padding:0',
|
||||
'font:inherit',
|
||||
'color:inherit',
|
||||
'opacity:0.7',
|
||||
'text-decoration:underline',
|
||||
]);
|
||||
const onRemove = () => canvas.onDeleteSection(section);
|
||||
remove.addEventListener('click', onRemove);
|
||||
undo.push(() => remove.removeEventListener('click', onRemove));
|
||||
|
||||
bar.append(label, shape, remove);
|
||||
element.append(bar);
|
||||
undo.push(() => bar.remove());
|
||||
|
||||
/*
|
||||
The columns, outlined too.
|
||||
|
||||
An empty column has height and nothing else, so a two-column band and a one-column band look
|
||||
identical until something is in them — which is exactly when it is too late to notice you chose
|
||||
the wrong one.
|
||||
*/
|
||||
element.querySelectorAll<HTMLElement>('[data-column]').forEach((column) => {
|
||||
const was = column.style.outline;
|
||||
const wasOffset = column.style.outlineOffset;
|
||||
column.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
|
||||
column.style.outlineOffset = '-4px';
|
||||
undo.push(() => {
|
||||
column.style.outline = was;
|
||||
column.style.outlineOffset = wasOffset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Add section, always last ─────────────────────────────────────────────────────────────────
|
||||
const zone = document.createElement('div');
|
||||
zone.setAttribute(MARK, 'add-section');
|
||||
zone.style.cssText = [
|
||||
style(zone, [
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'min-height:40vh',
|
||||
'margin:2rem 0',
|
||||
// A dashed outline reads as "a space waiting for something" in every editor anybody has used.
|
||||
// Tall when the page is empty, modest once there is something to add to — the zone should not
|
||||
// dwarf the content it sits under.
|
||||
canvas.sections.length ? 'min-height:8rem' : 'min-height:40vh',
|
||||
'margin:2.5rem 0',
|
||||
'border:2px dashed color-mix(in srgb, currentColor 25%, transparent)',
|
||||
'border-radius:0.75rem',
|
||||
].join(';');
|
||||
]);
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '+ Add section';
|
||||
button.style.cssText = [
|
||||
style(button, [
|
||||
'cursor:pointer',
|
||||
'padding:0.75rem 1.5rem',
|
||||
'border-radius:0.5rem',
|
||||
// The page's own colours, inverted — so the control belongs to whatever theme the site is wearing
|
||||
// rather than being a grey box sitting on it.
|
||||
// The page's own colours, inverted, so the control belongs to whatever theme the site is wearing.
|
||||
'background:var(--site-fg, #000)',
|
||||
'color:var(--site-bg, #fff)',
|
||||
'border:0',
|
||||
'font:inherit',
|
||||
'font-size:0.875rem',
|
||||
].join(';');
|
||||
]);
|
||||
button.addEventListener('click', canvas.onAddSection);
|
||||
|
||||
zone.append(button);
|
||||
root.append(zone);
|
||||
|
||||
return () => {
|
||||
undo.push(() => {
|
||||
button.removeEventListener('click', canvas.onAddSection);
|
||||
zone.remove();
|
||||
if (placeholder) placeholder.style.display = '';
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Reversed, so a style put back is put back over the value that replaced it rather than under.
|
||||
for (const step of undo.reverse()) step();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { readableError } from '../errors';
|
||||
import { SitePreview, useDevPort } from '../SitePreview';
|
||||
import { paintCanvas } from '../canvas';
|
||||
import { paintCanvas, type CanvasSection } from '../canvas';
|
||||
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
|
||||
import { usePages, type Page } from '../usePages';
|
||||
|
||||
@@ -34,6 +34,17 @@ export const PageBuilderPanel = () => {
|
||||
const { pages, announce } = usePages();
|
||||
|
||||
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 }>({ add: () => {}, remove: () => {} });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
/*
|
||||
@@ -52,10 +63,34 @@ export const PageBuilderPanel = () => {
|
||||
be a new injection each render.
|
||||
*/
|
||||
const onDocument = useCallback(
|
||||
(frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAdding(true) }),
|
||||
[],
|
||||
(frameDocument: Document) =>
|
||||
paintCanvas(frameDocument, {
|
||||
sections,
|
||||
onAddSection: () => handlersRef.current.add(),
|
||||
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
||||
}),
|
||||
[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);
|
||||
@@ -89,6 +124,25 @@ export const PageBuilderPanel = () => {
|
||||
|
||||
const page = pages?.find((row) => row.id === id) ?? null;
|
||||
|
||||
/*
|
||||
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[] }>(`/fofos-admin/pages/${page.id}/sections`)
|
||||
.then((response) => setSections(response.data))
|
||||
.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.
|
||||
handlersRef.current = { add: () => setAdding(true), remove: (sectionId: number) => void deleteSection(sectionId) };
|
||||
|
||||
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>;
|
||||
|
||||
@@ -107,6 +161,8 @@ export const PageBuilderPanel = () => {
|
||||
|
||||
{/* 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
|
||||
|
||||
Reference in New Issue
Block a user