the builder draws the sections it finds
An empty section renders as nothing. That is correct on the public site and useless in an editor: somebody who has just chosen "two columns" sees a blank page and has to take it on trust. So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines the columns inside it, because a one-column band and a two-column band are identical until something is in them, which is exactly when it is too late to notice the wrong choice. The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column. Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that has never heard of this file, and read as what they are. That is the whole contract — one attribute on the public site buys the entire editor. Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came from and nothing else; inferring the layout from its classes would be the admin reading the site's stylesheet, which is the coupling all of this exists to avoid. ── The race, and why the fix is not another ref ── Creating a section starts two things that do not finish in a fixed order: the frame fetches a new document, and the panel fetches the sections to draw on it. Painting only on load meant painting with whatever had arrived by then, which after a create was usually the data from before it existed. So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a dependency, and whichever arrives second triggers the paint that counts. The caller then controls repainting through its own useCallback dependencies, which is what those are for. The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and would repaint on every one; the sections are the thing being drawn and should be exactly what decides when to redraw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+151
-30
@@ -9,40 +9,58 @@
|
||||
*
|
||||
* 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.
|
||||
* what the whole page builder rests on.
|
||||
*
|
||||
* ── What the SITE has to provide, and it is only this ──
|
||||
*
|
||||
* data-page-empty the placeholder shown when a page has no sections
|
||||
* data-section-id which row a band came from
|
||||
* data-column a column within a band
|
||||
*
|
||||
* Three attributes and no behaviour. Markers rather than classes, so they cannot be mistaken for
|
||||
* styling, cannot be purged by a build that has never heard of this file, and read as what they are:
|
||||
* handles for something else.
|
||||
*
|
||||
* ── 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.
|
||||
* that reloads under it whenever the dev server rebuilds. These are a handful of elements with click
|
||||
* handlers; building them by hand costs a few lines, and a reload becomes 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.
|
||||
* nothing.
|
||||
*/
|
||||
|
||||
/** Everything injected carries this, so a re-injection can find and remove what the last one left. */
|
||||
const MARK = 'data-fofos-admin-canvas';
|
||||
|
||||
/** The editor's own colour. Deliberately not the site's — this is chrome, and it should not blend in. */
|
||||
const ACCENT = '#3b82f6';
|
||||
|
||||
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two' };
|
||||
|
||||
export type Canvas = {
|
||||
/** Somebody pressed Add Section. */
|
||||
sections: CanvasSection[];
|
||||
onAddSection: () => void;
|
||||
onDeleteSection: (section: CanvasSection) => 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.
|
||||
* furniture around it.
|
||||
*/
|
||||
const contentRoot = (document: Document): HTMLElement | null => document.querySelector('main');
|
||||
|
||||
const style = (element: HTMLElement, rules: string[]) => {
|
||||
element.style.cssText = rules.join(';');
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw the builder's controls into a rendered page.
|
||||
*
|
||||
@@ -54,58 +72,161 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
||||
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.
|
||||
// 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());
|
||||
|
||||
/** Everything to undo, in the order it was done. */
|
||||
const undo: (() => void)[] = [];
|
||||
|
||||
/*
|
||||
The site's EMPTY-PAGE placeholder, hidden while the builder draws its own controls in that space.
|
||||
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.
|
||||
Found by its marker rather than by position. Hidden rather than removed, so the cleanup puts it
|
||||
back — a page whose last section is deleted shows it again without a reload.
|
||||
*/
|
||||
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'none';
|
||||
undo.push(() => {
|
||||
placeholder.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Every section, outlined and named.
|
||||
|
||||
This is the whole reason the decoration exists: an EMPTY section renders as nothing, which is
|
||||
correct on the public site and useless in an editor. Somebody who has just chosen "two columns"
|
||||
needs to see two columns, not a blank page they have to take on trust.
|
||||
*/
|
||||
for (const section of canvas.sections) {
|
||||
const element = root.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
|
||||
if (!element) continue;
|
||||
|
||||
const previousPosition = element.style.position;
|
||||
const previousOutline = element.style.outline;
|
||||
const previousOffset = element.style.outlineOffset;
|
||||
const previousMargin = element.style.margin;
|
||||
|
||||
// `relative` so the label and the controls can be placed against this band rather than the page.
|
||||
element.style.position = 'relative';
|
||||
element.style.outline = `1px solid ${ACCENT}`;
|
||||
element.style.outlineOffset = '4px';
|
||||
// Room for the outline and the label above it, so two bands in a row do not touch.
|
||||
element.style.margin = '2.5rem 0';
|
||||
|
||||
undo.push(() => {
|
||||
element.style.position = previousPosition;
|
||||
element.style.outline = previousOutline;
|
||||
element.style.outlineOffset = previousOffset;
|
||||
element.style.margin = previousMargin;
|
||||
});
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.setAttribute(MARK, 'section-bar');
|
||||
style(bar, [
|
||||
'position:absolute',
|
||||
'top:-1.75rem',
|
||||
'left:0',
|
||||
'right:0',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'gap:0.5rem',
|
||||
'font-size:0.6875rem',
|
||||
`color:${ACCENT}`,
|
||||
// The page's own font would make this look like content. It is not content.
|
||||
'font-family:ui-sans-serif, system-ui, sans-serif',
|
||||
]);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = section.name;
|
||||
style(label, ['font-weight:600']);
|
||||
|
||||
const shape = document.createElement('span');
|
||||
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
|
||||
style(shape, ['opacity:0.7']);
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Delete';
|
||||
style(remove, [
|
||||
'margin-left:auto',
|
||||
'cursor:pointer',
|
||||
'background:none',
|
||||
'border:0',
|
||||
'padding:0',
|
||||
'font:inherit',
|
||||
'color:inherit',
|
||||
'opacity:0.7',
|
||||
'text-decoration:underline',
|
||||
]);
|
||||
const onRemove = () => canvas.onDeleteSection(section);
|
||||
remove.addEventListener('click', onRemove);
|
||||
undo.push(() => remove.removeEventListener('click', onRemove));
|
||||
|
||||
bar.append(label, shape, remove);
|
||||
element.append(bar);
|
||||
undo.push(() => bar.remove());
|
||||
|
||||
/*
|
||||
The columns, outlined too.
|
||||
|
||||
An empty column has height and nothing else, so a two-column band and a one-column band look
|
||||
identical until something is in them — which is exactly when it is too late to notice you chose
|
||||
the wrong one.
|
||||
*/
|
||||
element.querySelectorAll<HTMLElement>('[data-column]').forEach((column) => {
|
||||
const was = column.style.outline;
|
||||
const wasOffset = column.style.outlineOffset;
|
||||
column.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
|
||||
column.style.outlineOffset = '-4px';
|
||||
undo.push(() => {
|
||||
column.style.outline = was;
|
||||
column.style.outlineOffset = wasOffset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Add section, always last ─────────────────────────────────────────────────────────────────
|
||||
const zone = document.createElement('div');
|
||||
zone.setAttribute(MARK, 'add-section');
|
||||
zone.style.cssText = [
|
||||
style(zone, [
|
||||
'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.
|
||||
// Tall when the page is empty, modest once there is something to add to — the zone should not
|
||||
// dwarf the content it sits under.
|
||||
canvas.sections.length ? 'min-height:8rem' : 'min-height:40vh',
|
||||
'margin:2.5rem 0',
|
||||
'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 = [
|
||||
style(button, [
|
||||
'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.
|
||||
// The page's own colours, inverted, so the control belongs to whatever theme the site is wearing.
|
||||
'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 () => {
|
||||
undo.push(() => {
|
||||
button.removeEventListener('click', canvas.onAddSection);
|
||||
zone.remove();
|
||||
if (placeholder) placeholder.style.display = '';
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Reversed, so a style put back is put back over the value that replaced it rather than under.
|
||||
for (const step of undo.reverse()) step();
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user