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>
This commit is contained in:
+38
-1
@@ -44,6 +44,7 @@ export const SitePreview = ({
|
|||||||
port,
|
port,
|
||||||
path,
|
path,
|
||||||
theme,
|
theme,
|
||||||
|
onDocument,
|
||||||
}: {
|
}: {
|
||||||
port: number | null;
|
port: number | null;
|
||||||
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
|
/** 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.
|
* is editing the variables the page reads, so it can show a change without a save or a reload.
|
||||||
*/
|
*/
|
||||||
theme?: Record<string, string | null>;
|
theme?: Record<string, string | null>;
|
||||||
|
/**
|
||||||
|
* 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();
|
const client = useClient();
|
||||||
/*
|
/*
|
||||||
@@ -142,6 +154,28 @@ export const SitePreview = ({
|
|||||||
applyTheme();
|
applyTheme();
|
||||||
}, [applyTheme, ready]);
|
}, [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 =
|
const message =
|
||||||
!port ? 'No development server is configured — set its port under Administration.'
|
!port ? 'No development server is configured — set its port under Administration.'
|
||||||
: failed ? `Could not authorise the preview: ${failed}`
|
: failed ? `Could not authorise the preview: ${failed}`
|
||||||
@@ -190,7 +224,10 @@ export const SitePreview = ({
|
|||||||
ref={frame}
|
ref={frame}
|
||||||
// Applied on load as well as on change: the document is replaced on a reload, and the
|
// Applied on load as well as on change: the document is replaced on a reload, and the
|
||||||
// properties go with it.
|
// properties go with it.
|
||||||
onLoad={applyTheme}
|
onLoad={() => {
|
||||||
|
applyTheme();
|
||||||
|
handOverDocument();
|
||||||
|
}}
|
||||||
src={`/api/dev-proxy/${port}/${path}`}
|
src={`/api/dev-proxy/${port}/${path}`}
|
||||||
title={`The site at /${path}`}
|
title={`The site at /${path}`}
|
||||||
className="border-0"
|
className="border-0"
|
||||||
|
|||||||
+109
@@ -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.
|
||||||
|
*
|
||||||
|
* `<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 = '';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { SitePreview, useDevPort } from '../SitePreview';
|
import { SitePreview, useDevPort } from '../SitePreview';
|
||||||
|
import { paintCanvas } from '../canvas';
|
||||||
import { usePages, type Page } from '../usePages';
|
import { usePages, type Page } from '../usePages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +33,19 @@ export const PageBuilderPanel = () => {
|
|||||||
const { pages, announce } = usePages();
|
const { pages, announce } = usePages();
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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.
|
The list, if nobody has announced it yet.
|
||||||
@@ -66,18 +80,23 @@ export const PageBuilderPanel = () => {
|
|||||||
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The real page, at its own address. `path` has no leading slash — Home's is the empty string,
|
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path`
|
||||||
which the proxy resolves to the site's root. */}
|
has no leading slash — Home's is the empty string, which the proxy resolves to the root. */}
|
||||||
<SitePreview port={devPort} path={page.slug} />
|
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
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
|
Temporary, and it earns its place until the section picker exists: it is the only visible proof
|
||||||
that failed to load. This is the next thing to build and the panel should say so.
|
that a button drawn inside the rendered page reaches React out here. When there are sections to
|
||||||
|
choose, this becomes the chooser.
|
||||||
*/}
|
*/}
|
||||||
<div className="shrink-0 rounded-lg border border-dashed border-border p-4 text-center">
|
<div className="shrink-0 rounded-lg border border-dashed border-border p-3 text-center">
|
||||||
<p className="text-xs text-muted-foreground">The blocks that make up this page go here.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{asked === 0
|
||||||
|
? 'Press Add section on the page.'
|
||||||
|
: `Add section pressed ${asked} time${asked === 1 ? '' : 's'} — the page is talking to the panel.`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user