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>
112 lines
4.7 KiB
TypeScript
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 = '';
|
|
};
|
|
}
|