Files
fofos-admin/web/canvas.ts
T
pastilhasandClaude Opus 5 d185382d84 show the space the page is not using
Two hairlines said where the measure ends and left the reader to notice that everything outside them
was empty. The margins are tinted now, faintly, so the unused width reads as what it is — room the page
does not occupy, which is the question somebody widening a gutter or judging a desktop layout is
actually asking.

Faint on purpose: it is absence, and anything stronger competes with the content for the eye. Nothing
is drawn at all when the frame is no wider than the measure, which is the existing early return.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 03:21:26 +01:00

634 lines
26 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.
*
* ── 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
* data-content-column the wrapper that holds the page to its measure
*
* 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. 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.
*/
/** 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';
/**
* A component, as the canvas needs it.
*
* `content` is carried even though nothing in this file draws it: the panel reads it back when somebody
* presses Edit, and it arrived with the section. Fetching it again on every Edit would be a request to
* be told something already in hand.
*/
export type CanvasComponent = {
id: number;
column: number;
kind: string;
/*
One optional field per kind that the panel reads back. Not `Record<string, unknown>`: every reader
would then need a cast, and the point of naming them is that the compiler knows `free-text` holds
`html` and `gallery` holds `galleryId`.
*/
content?: { html?: string; galleryId?: number | null };
padding?: Record<string, { x?: number; y?: number }>;
};
export type CanvasSection = {
id: number;
name: string;
layout: 'one' | 'two';
padding?: Record<string, { x?: number; y?: number }>;
components: CanvasComponent[];
};
export type Canvas = {
sections: CanvasSection[];
onAddSection: () => void;
onDeleteSection: (section: CanvasSection) => void;
/** Somebody wants to change the space around a band or around one thing in it. */
onSpaceSection: (section: CanvasSection) => void;
onSpaceComponent: (component: CanvasComponent) => void;
/** A column wants something in it. */
onAddComponent: (section: CanvasSection, column: number) => void;
/** Something already in a column was pressed. */
onEditComponent: (component: CanvasComponent) => void;
/**
* Whether this component has anything to edit — no button if not.
*
* A predicate from the panel rather than a list of kinds here, because which kinds are editable is
* the same knowledge that decides WHICH editor opens, and that already lives there. A contact form
* and a partner row are code the site owns: the block places them and there is nothing to type, so
* an Edit button would be a door onto an empty room.
*
* Absent means editable, so a caller that has never thought about it gets what it had before.
*/
canEditComponent?: (component: CanvasComponent) => boolean;
onDeleteComponent: (component: CanvasComponent) => 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.
*/
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.
*
* 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.
// Across the whole document, not just `main`: the measure guides are appended to body so they can be
// `fixed` without a positioned ancestor interfering.
document.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
/** Everything to undo, in the order it was done. */
const undo: (() => void)[] = [];
/*
A stylesheet, for the one thing inline styles cannot express: `:hover`.
The component tools sat permanently at the top-right of whatever they belonged to, on top of the
text. On a paragraph that fills its column there is nowhere for them to go that is not on top of
something — so they are hidden until the component is hovered, which is when they are wanted and
the only time they are.
The outline goes the same way. An editor should show the page, with the handles arriving under the
cursor; an editor that outlines everything all the time is showing you the editor.
*/
const sheet = document.createElement('style');
sheet.setAttribute(MARK, 'stylesheet');
sheet.textContent = `
/*
EVERYTHING belonging to a section appears when that section is hovered.
Its outline, its name, its shape, its Delete, the outlines of its columns and the button that adds
to them. A page with four bands was four labelled boxes at rest, and the page underneath was the
one thing you could not see — which is what an editor is for.
Opacity rather than display, so nothing moves when it appears: the controls hold their space
whether or not they are visible, and the page does not reflow under the cursor.
*/
[data-section-id] {
outline: 1px solid transparent;
outline-offset: -1px;
transition: outline-color .12s;
}
[data-section-id]:hover { outline-color: ${ACCENT}; }
[${MARK}="section-bar"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="section-bar"] { opacity: 1; }
/* Keyboard reaches them too: focus inside a band is the same intent as hovering it. */
[data-section-id]:focus-within [${MARK}="section-bar"] { opacity: 1; }
[data-column] {
outline: 1px dashed transparent;
outline-offset: -5px;
transition: outline-color .12s;
}
[data-section-id]:hover [data-column] {
outline-color: color-mix(in srgb, ${ACCENT} 45%, transparent);
}
[${MARK}="add-component"] { opacity: 0; transition: opacity .12s; }
[data-section-id]:hover [${MARK}="add-component"] { opacity: 1; }
[${MARK}="add-component"]:focus { opacity: 1; }
/*
A COMPONENT's own handles need their own hover, not the section's. A band with three paragraphs
would otherwise show three sets of Edit and Delete at once, and none of them would say which.
*/
[data-fofos-component] { outline: 1px dotted transparent; transition: outline-color .12s; }
[data-fofos-component]:hover { outline-color: color-mix(in srgb, ${ACCENT} 55%, transparent); }
[${MARK}="component-tools"] { opacity: 0; transition: opacity .12s; }
[data-fofos-component]:hover > [${MARK}="component-tools"] { opacity: 1; }
[${MARK}="component-tools"]:focus-within { opacity: 1; }
`;
document.head.append(sheet);
undo.push(() => sheet.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. 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';
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;
// `relative` so the label and the controls can be placed against this band rather than the page.
element.style.position = 'relative';
/*
NO margin. There was `2.5rem 0` here, to make room for the label above each band.
That is 40 pixels above and below every section that the real page does not have, and it made the
preview overstate the spacing of every page in the site — on the one screen whose entire purpose
is to show what the page looks like. It was reported as "there is a lot more space between the
logo and the text in the admin", which is exactly what it was.
A preview may draw ON the page. It may not CHANGE it. Everything this file adds is now either
absolutely positioned or an outline, both of which take no layout space, and the label moved
inside the band it names — see the bar below.
*/
undo.push(() => {
element.style.position = previousPosition;
});
const bar = document.createElement('div');
bar.setAttribute(MARK, 'section-bar');
style(bar, [
'position:absolute',
// INSIDE the band, not above it. Above needed 40px of margin the page does not have; the bar is
// hover-only, so overlaying the first line of a band for as long as the pointer is on it costs
// nothing and lying about the layout costs the preview its whole point.
'top:0',
'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']);
/*
Spacing before Delete, and both on the right.
The order is how often they are wanted, not how they read: spacing is an ordinary adjustment and
deleting a band is not, so the destructive one sits furthest from where the hand arrives.
*/
const actions: HTMLElement[] = [];
for (const [text, run, first] of [
['Spacing', () => canvas.onSpaceSection(section), true],
['Delete', () => canvas.onDeleteSection(section), false],
] as const) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = text;
style(action, [
first ? 'margin-left:auto' : '',
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'opacity:0.7',
'text-decoration:underline',
]);
action.addEventListener('click', run);
undo.push(() => action.removeEventListener('click', run));
actions.push(action);
}
bar.append(label, shape, ...actions);
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((columnElement, columnIndex) => {
/*
Every component in this column gets a handle.
Drawn ON the rendered component rather than replacing it — an editor that swaps content for a
placeholder is showing you the editor, not the page. Free text with nothing written in it
renders as nothing, so the outline is the only thing saying it is there at all.
*/
const mine = section.components.filter((component) => component.column === columnIndex);
for (const component of mine) {
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
if (!node) continue;
const wasPosition = node.style.position;
node.style.position = 'relative';
// The outline and the tools' visibility are the stylesheet's, so they can depend on :hover.
// The ID, not an empty marker: the panel writes a draft into this node as somebody types, and
// has to be able to find the one being edited.
node.setAttribute('data-fofos-component', String(component.id));
undo.push(() => {
node.style.position = wasPosition;
node.removeAttribute('data-fofos-component');
});
const tools = document.createElement('div');
tools.setAttribute(MARK, 'component-tools');
style(tools, [
'position:absolute',
// Above its own top-right corner rather than inside it, so even while visible it sits in the
// gap between components instead of over the first line of this one.
'top:-1.25rem',
'right:0',
'display:flex',
'gap:0.5rem',
'padding:0 0.25rem',
'font-size:0.625rem',
'font-family:ui-sans-serif, system-ui, sans-serif',
`color:${ACCENT}`,
'background:var(--site-bg, #fff)',
// Above the page, below the section bar, and never a target when invisible.
'z-index:5',
]);
const actions: [string, () => void][] = [
['Spacing', () => canvas.onSpaceComponent(component)],
['Delete', () => canvas.onDeleteComponent(component)],
];
// First, so the tools read Edit / Spacing / Delete — the order they were in when Edit was
// unconditional, rather than the order the array happens to be built in.
if (canvas.canEditComponent?.(component) ?? true) {
actions.unshift(['Edit', () => canvas.onEditComponent(component)]);
}
for (const [label, run] of actions) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = label;
style(action, [
'cursor:pointer',
'background:none',
'border:0',
'padding:0',
'font:inherit',
'color:inherit',
'text-decoration:underline',
]);
action.addEventListener('click', run);
undo.push(() => action.removeEventListener('click', run));
tools.append(action);
}
node.append(tools);
undo.push(() => tools.remove());
}
/*
And a way to put something in it.
One per column, always at the bottom, so a column that already has three paragraphs still has
somewhere obvious to add a fourth. Quieter than Add section — this is the inner loop, and a
button as loud as the one that adds a whole band would make every column shout.
*/
const add = document.createElement('button');
add.setAttribute(MARK, 'add-component');
add.type = 'button';
add.textContent = '+ Add component';
style(add, [
'display:block',
'width:100%',
'margin-top:0.75rem',
'padding:0.5rem',
'cursor:pointer',
'background:none',
`border:1px dashed color-mix(in srgb, ${ACCENT} 40%, transparent)`,
'border-radius:0.375rem',
`color:${ACCENT}`,
'font-family:ui-sans-serif, system-ui, sans-serif',
'font-size:0.75rem',
]);
const onAdd = () => canvas.onAddComponent(section, columnIndex);
add.addEventListener('click', onAdd);
columnElement.append(add);
undo.push(() => {
add.removeEventListener('click', onAdd);
add.remove();
});
});
}
// The measure. Drawn for anything that shows a page — see paintMeasure.
undo.push(paintMeasure(document));
// ── Add section, always last ─────────────────────────────────────────────────────────────────
/*
Add section goes at the END OF THE PAGE, held to the measure by hand.
It used to be appended to the content column, which was one element wrapping every section. It is
not one element any more: a built page is full-bleed so each band can draw a separator the width of
the window, and each holds its OWN content to the measure. Appending to the first thing matching
`[data-content-column]` would now put "add a section" inside section one.
So it goes in `main`, and the width comes from a column rather than from a constant — the measure is
Tailwind's `max-w-7xl` and this file has no business knowing that. With no section on the page there
is no column to ask, and full width is right anyway: there is nothing to line up with.
*/
const zoneParent = root;
const measure = root.querySelector<HTMLElement>('[data-content-column]')?.getBoundingClientRect().width;
const zone = document.createElement('div');
zone.setAttribute(MARK, 'add-section');
style(zone, [
'display:flex',
'align-items:center',
'justify-content:center',
// 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',
]);
const button = document.createElement('button');
button.type = 'button';
button.textContent = '+ Add section';
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.
'background:var(--site-fg, #000)',
'color:var(--site-bg, #fff)',
'border:0',
'font:inherit',
'font-size:0.875rem',
]);
button.addEventListener('click', canvas.onAddSection);
zone.append(button);
// Aligned with the content rather than with the window, so the zone describes a place a section can
// actually go. `auto` margins do nothing until there is a width to centre.
if (measure) {
zone.style.maxWidth = `${Math.round(measure)}px`;
zone.style.marginInline = 'auto';
}
zoneParent.append(zone);
undo.push(() => {
button.removeEventListener('click', canvas.onAddSection);
zone.remove();
});
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();
};
}
/**
* The content column's edges, as two lines and a width.
*
* ── Why this is on its own ──
*
* Every preview wants it and only one of them wants the rest. The page builder draws sections, columns
* and a drop zone; the Layout section draws none of those — its bands are code and nothing there can be
* rearranged — but it has exactly the same question about where the measure ends.
*
* ── Why it needs saying at all ──
*
* The page is held to `max-w-7xl` and centred, so on a wide screen the content stops well short of the
* window and nothing marks where. That is invisible until something is placed against the edge and
* turns out to be in the wrong place, which is the moment it becomes expensive.
*
* Returns a cleanup, like everything else injected here.
*/
export function paintMeasure(document: Document): () => void {
const root = document.querySelector<HTMLElement>('main');
const column = root?.querySelector<HTMLElement>('[data-content-column]');
if (!column) return () => {};
const guides: HTMLElement[] = [];
const place = () => {
for (const guide of guides) guide.remove();
guides.length = 0;
const box = column.getBoundingClientRect();
// Below the frame's own width there is no measure to show — the column simply IS the page, and two
// lines against the edges would be noise claiming to be information.
if (box.width >= (document.documentElement.clientWidth || 0) - 1) return;
/*
The margins THEMSELVES, not only the lines that bound them.
Two hairlines say where the measure ends and leave the reader to notice that the space outside is
empty. Tinting it says what that space IS: room the page is not using, which is the question
somebody widening a gutter or judging a desktop layout is actually asking. On a narrow frame there
is nothing to tint and the block below returns before drawing any of it.
*/
for (const [side, from, width] of [
['left', 0, box.left],
['right', box.right, (document.documentElement.clientWidth || 0) - box.right],
] as const) {
if (width <= 0) continue;
const margin = document.createElement('div');
margin.setAttribute(MARK, `measure-margin-${side}`);
style(margin, [
'position:fixed',
'top:0',
'bottom:0',
`left:${Math.round(from)}px`,
`width:${Math.round(width)}px`,
// Faint, because this is the part of the screen the page does NOT occupy: it has to read as
// absence. Anything stronger competes with the content for the eye.
`background:color-mix(in srgb, ${ACCENT} 7%, transparent)`,
'pointer-events:none',
'z-index:2147482999',
]);
document.body.append(margin);
guides.push(margin);
}
for (const [side, x] of [
['left', box.left],
['right', box.right],
] as const) {
const guide = document.createElement('div');
guide.setAttribute(MARK, `measure-${side}`);
style(guide, [
// `fixed`, so they stay while the page scrolls. A guide that scrolls away is a guide for the
// top of the page only.
'position:fixed',
'top:0',
'bottom:0',
`left:${Math.round(x)}px`,
'width:1px',
`background:color-mix(in srgb, ${ACCENT} 35%, transparent)`,
// It must not swallow a click meant for the page underneath it.
'pointer-events:none',
'z-index:2147483000',
]);
document.body.append(guide);
guides.push(guide);
}
const label = document.createElement('div');
label.setAttribute(MARK, 'measure-label');
label.textContent = `${Math.round(box.width)}px`;
style(label, [
'position:fixed',
'top:0.25rem',
`left:${Math.round(box.left + 4)}px`,
'font-size:0.625rem',
'font-family:ui-sans-serif, system-ui, sans-serif',
`color:${ACCENT}`,
'opacity:0.8',
'pointer-events:none',
'z-index:2147483000',
]);
document.body.append(label);
guides.push(label);
};
place();
// The frame's width changes when the device does, and a remount is not the only way that happens.
const frameWindow = document.defaultView;
frameWindow?.addEventListener('resize', place);
return () => {
frameWindow?.removeEventListener('resize', place);
for (const guide of guides) guide.remove();
};
}
/**
* The site's gutter, applied to a rendered page before it is saved.
*
* ── Why this can be classes and not variables ──
*
* Colours and typefaces go into the frame as CSS custom properties, because that is what the page reads
* them as. A gutter is Tailwind classes — `px-4 md:px-8` — because only a class can carry a breakpoint,
* and there is no variable to set instead.
*
* That works here for one reason: the classes are in the SITE's stylesheet, which the frame already
* has. The site safelists every step (`@source inline` in styles.css), so any value this writes is one
* the frame can already render. A class invented here would silently do nothing.
*
* ── Only gutter classes are touched ──
*
* The elements carrying `data-gutter` hold the gutter and nothing else that looks like it: a section's
* own padding lands on the <section>, and the band padding on <header>. So removing everything matching
* `px-N` from these three is safe, where doing it to an arbitrary element would not be.
*/
export function applyGutter(document: Document, classes: string): () => void {
const targets = [...document.querySelectorAll<HTMLElement>('[data-gutter]')];
const before = targets.map((element) => element.className);
const GUTTER = /^(?:md:|lg:)?px-\d+$/;
for (const element of targets) {
const kept = element.className.split(/\s+/).filter((name) => name && !GUTTER.test(name));
element.className = [...kept, ...classes.split(/\s+/).filter(Boolean)].join(' ');
}
return () => {
targets.forEach((element, index) => {
element.className = before[index]!;
});
};
}