diff --git a/web/SitePreview.tsx b/web/SitePreview.tsx index 8032fdf..0db8cbd 100644 --- a/web/SitePreview.tsx +++ b/web/SitePreview.tsx @@ -44,6 +44,7 @@ export const SitePreview = ({ port, path, theme, + onDocument, }: { port: number | null; /** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */ @@ -55,6 +56,17 @@ export const SitePreview = ({ * is editing the variables the page reads, so it can show a change without a save or a reload. */ theme?: Record; + /** + * Called with the frame's own document once it has loaded, and again after every reload. + * + * This is the door the page builder goes through. The frame is same-origin, so a caller can read the + * rendered page's DOM and put its own elements inside it — chrome that belongs to the ADMIN, drawn on + * the real page, without a line of it existing in the website's source. + * + * Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the + * document it was injected into. + */ + onDocument?: (document: Document) => void | (() => void); }) => { const client = useClient(); /* @@ -142,6 +154,28 @@ export const SitePreview = ({ applyTheme(); }, [applyTheme, ready]); + /* + Hand the loaded document to whoever asked for it. + + Through a ref so a caller's inline arrow — which is a new function every render — does not re-run + the injection on every render. Same trap as `useClient`, one level along. + */ + const onDocumentRef = useRef(onDocument); + onDocumentRef.current = onDocument; + const cleanupRef = useRef<(() => void) | void>(undefined); + + const handOverDocument = useCallback(() => { + // Whatever was injected into the PREVIOUS document goes first. A reload replaces the document, so + // a cleanup that runs after the fact is cleaning something that no longer exists. + cleanupRef.current?.(); + cleanupRef.current = undefined; + + const frameDocument = frame.current?.contentDocument; + if (frameDocument) cleanupRef.current = onDocumentRef.current?.(frameDocument); + }, []); + + useEffect(() => () => cleanupRef.current?.(), []); + const message = !port ? 'No development server is configured — set its port under Administration.' : failed ? `Could not authorise the preview: ${failed}` @@ -190,7 +224,10 @@ export const SitePreview = ({ ref={frame} // Applied on load as well as on change: the document is replaced on a reload, and the // properties go with it. - onLoad={applyTheme} + onLoad={() => { + applyTheme(); + handOverDocument(); + }} src={`/api/dev-proxy/${port}/${path}`} title={`The site at /${path}`} className="border-0" diff --git a/web/canvas.ts b/web/canvas.ts new file mode 100644 index 0000000..f943665 --- /dev/null +++ b/web/canvas.ts @@ -0,0 +1,109 @@ +/** + * 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 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 = ''; + }; +} diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index e952a05..d977907 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; +import { paintCanvas } from '../canvas'; import { usePages, type Page } from '../usePages'; /** @@ -32,6 +33,19 @@ export const PageBuilderPanel = () => { const { pages, announce } = usePages(); const [error, setError] = useState(null); + /** Proof the frame is talking back. Replaced by the section picker once there are sections to pick. */ + const [asked, setAsked] = useState(0); + + /* + The builder's chrome, painted into the rendered page. + + Stable, because SitePreview holds it and re-runs it on every load. A new function each render would + be a new injection each render. + */ + const onDocument = useCallback( + (frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAsked((n) => n + 1) }), + [], + ); /* The list, if nobody has announced it yet. @@ -66,18 +80,23 @@ export const PageBuilderPanel = () => { {page.path} - {/* The real page, at its own address. `path` has no leading slash — Home's is the empty string, - which the proxy resolves to the site's root. */} - + {/* The real page, at its own address, with the builder's own controls drawn inside it. `path` + has no leading slash — Home's is the empty string, which the proxy resolves to the root. */} + {/* - Where the blocks go. + The other end of the wire. - Said rather than left blank, because an empty panel under a working preview reads as something - that failed to load. This is the next thing to build and the panel should say so. + Temporary, and it earns its place until the section picker exists: it is the only visible proof + that a button drawn inside the rendered page reaches React out here. When there are sections to + choose, this becomes the chooser. */} -
-

The blocks that make up this page go here.

+
+

+ {asked === 0 + ? 'Press Add section on the page.' + : `Add section pressed ${asked} time${asked === 1 ? '' : 's'} — the page is talking to the panel.`} +

);