/** * 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. * * `
` 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('[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 = ''; }; }