Files
fofos-admin/web/canvas.ts
T
pastilhasandClaude Opus 5 5af0d9d9d1 the builder's chrome is drawn inside the rendered page
A component that lives at the PLUGIN level and appears on the site's page: an empty drop zone with an
Add section button, injected into the frame at runtime. Nothing of it exists in the website's source,
so the site never carries code only an editor would see, and the builder can change without the site
being rebuilt.

It works because the preview frame is SAME ORIGIN — through the platform's dev proxy, on Officer's own
host — so contentDocument is readable and writable. That single fact is what the 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.

SitePreview grew one prop, `onDocument`, called with the frame's document after every load and
returning a cleanup that runs before the next one. So a reload never leaves two sets of controls
behind, and nothing injected outlives the document it went into.

Plain DOM rather than React. Portalling would mean running a reconciler against a document that reloads
underneath it whenever the dev server rebuilds; these are a handful of elements with a click handler,
and this way a reload is a document that no longer exists rather than a tree React still thinks it owns.

Inline styles, not classes. The frame carries the SITE's stylesheet, built by scanning the site's own
templates — a class invented in this file was never scanned and would silently do nothing. The button
takes its colours from --site-fg and --site-bg, so it belongs to whatever theme the site is wearing.

The page's existing content is HIDDEN rather than removed, so the same call will leave real blocks
visible once there are any.

The panel counts the presses for now. That is the only visible proof the wire runs both ways, and it
becomes the section picker when there are sections to pick.

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

110 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 page's own content is hidden rather than removed.
Removed, it would be gone until the frame reloaded — and the builder wants to put controls AROUND
what is there, not instead of it. Today the page is the empty placeholder and hiding it is the whole
effect; tomorrow, when a page has blocks, the same call leaves them visible and this hides nothing.
*/
const placeholder = root.firstElementChild as HTMLElement | null;
const hadPlaceholder = Boolean(placeholder && !placeholder.hasAttribute(MARK));
if (hadPlaceholder && 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 (hadPlaceholder && placeholder) placeholder.style.display = '';
};
}