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:
2026-08-31 04:11:41 +01:00
co-authored by Claude Opus 5
parent e2a74e3337
commit 2ba9d5f303
3 changed files with 240 additions and 50 deletions
+151 -30
View File
@@ -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();
};
}