Files
fofos-admin/web/canvas.ts
T
pastilhasandClaude Opus 5 e2a74e3337 Add section asks what shape, and creates one
Pressing the button drawn inside the page opens a dialog: a name, and a choice between one column and
two, then Create section. The row lands in the database and the frame re-renders to show it.

The layouts are DRAWN rather than named. "One column" is words for a shape, and a shape is faster to
recognise than to read; the words stay underneath for anybody the picture does not reach.

The name is asked for and not generated. It never appears on the site — it exists so a page of eight
bands is navigable, and "Section 4" tells nobody anything, which is exactly the state a generated name
would leave every page in. Asked at the only moment somebody knows what they are about to build.

The frame is remounted after a write rather than the admin drawing the new band itself. The site
renders from the database; the admin does not know how to render a page, and if it learnt it would be
a second implementation of the very thing this preview exists to avoid.

The canvas now finds the empty-page placeholder by its `data-page-empty` marker instead of taking
`firstElementChild`. That was right for exactly as long as an empty page was the only kind — the moment
a page has a section, the first child IS that section, and it would have hidden the thing being edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:06:27 +01:00

112 lines
4.7 KiB
TypeScript

/**
* The admin's own chrome, drawn INSIDE the rendered page.
*
* ── What this is, and where it deliberately does not live ──
*
* Nothing here exists in the website's source. The site renders a page; this puts the builder's
* controls on top of it, from the plugin, at runtime. So the site never carries a line of code that
* only an editor would ever see, and the builder can change without the site being rebuilt.
*
* 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.
*
* ── 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.
*
* ── 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.
*/
/** Everything injected carries this, so a re-injection can find and remove what the last one left. */
const MARK = 'data-fofos-admin-canvas';
export type Canvas = {
/** Somebody pressed Add Section. */
onAddSection: () => 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.
*/
const contentRoot = (document: Document): HTMLElement | null => document.querySelector('main');
/**
* Draw the builder's controls into a rendered page.
*
* Returns a cleanup. It runs before the next injection and on unmount — see `onDocument` in
* SitePreview — so a reload never leaves two sets of controls behind.
*/
export function paintCanvas(document: Document, canvas: Canvas): () => void {
const root = contentRoot(document);
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.
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
/*
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.
*/
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
if (placeholder) placeholder.style.display = 'none';
const zone = document.createElement('div');
zone.setAttribute(MARK, 'add-section');
zone.style.cssText = [
'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.
'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 = [
'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.
'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 () => {
button.removeEventListener('click', canvas.onAddSection);
zone.remove();
if (placeholder) placeholder.style.display = '';
};
}