Files
fofos-admin/web/canvas.ts
T
pastilhasandClaude Opus 5 361684b8c5 every block is selectable, and has a Title
canEditComponent answers "is there content to type", and a heading is not
content — so the blocks with no editor of their own are now selectable too, and
get the heading alone.

The live draft writes into [data-block] when there is one. A titled component
renders as a wrapper, and writing into the wrapper would replace the heading with
the paragraph as somebody typed.

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

409 lines
18 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;
/** The heading the page draws above the block, or null. Every kind may have one. */
title?: string | null;
/*
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;
/** The heading the page draws above the band, or null. Not `name` — see the site's schema. */
title?: string | null;
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.
*/
/**
* Selection, and NOTHING drawn.
*
* ── What this replaced ──
*
* `paintCanvas` put a bar on every band with its name, its layout and a Delete; a row of Edit /
* Spacing / Delete above every block; a dotted outline around every block and column; and an add-zone
* at the end of the page. Look at that and you are looking at the editor, not the page — on a screen
* whose whole purpose is judging the page. Each was also a decision about type, spacing and colour
* that the site itself had not made.
*
* So this draws nothing at all: no elements, no stylesheet, no outlines. A click selects a block, and
* its editor opens in the panel beside the preview — where every other control now lives.
*
* ── Every block is selectable ──
*
* Including the ones with no editor of their own. `canEditComponent` answers "is there content to
* type", and since every block now has an optional heading, there is always something to open.
*
* ── The marker stays ──
*
* `data-fofos-component` is how the panel finds the block being edited so it can write a draft into it
* as somebody types. That is not chrome; it is the same kind of marker as `data-gutter`.
*
* ── What has no door any more ──
*
* Adding a section, adding a block, deleting either, and opening a spacing editor were all reached
* from the bars this removes. Their handlers still exist and still work; nothing calls them. They want
* a home in the editor column rather than painted onto the page.
*/
export function paintSelection(document: Document, canvas: Canvas): () => void {
const undo: (() => void)[] = [];
for (const section of canvas.sections) {
const element = document.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
if (!element) continue;
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
const mine = section.components.filter((component) => component.column === columnIndex);
for (const component of mine) {
// By position in the column, which is the order the API returns them in. The rendered node
// carries no id of its own.
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
if (!node) continue;
node.setAttribute('data-fofos-component', String(component.id));
undo.push(() => node.removeAttribute('data-fofos-component'));
const open = (event: MouseEvent) => {
// The page is real, so a block can hold a link and following it would leave the editor.
event.preventDefault();
event.stopPropagation();
canvas.onEditComponent(component);
};
node.addEventListener('click', open);
const wasCursor = node.style.cursor;
node.style.cursor = 'pointer';
undo.push(() => {
node.removeEventListener('click', open);
node.style.cursor = wasCursor;
});
}
});
}
return () => {
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;
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',
/*
Outside the measure, past the RIGHT guide.
It sat just inside the left edge, which is on top of the page — over the logo, on this site —
and it is not part of the page. The margin is the one place on screen that belongs to the
editor rather than to the site, so the number that describes the measure goes there.
Right rather than left because the eye reads the guide, then the width: the label is the answer
to the line it sits beside, and the right margin is the one a wide frame actually has room in.
*/
`left:${Math.round(box.right + 6)}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 spacing, 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. Spacing 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.
*
* ── Named bands, and why that replaced one gutter ──
*
* Every marked element used to take the same `px-*`. Now the header, the bands and the footer each have
* their own, so the marker carries which one it is — `data-gutter="header"`, `data-space="footer"` —
* and this rewrites one band without touching the other two.
*
* ── Only the matching utility is touched ──
*
* Each target has a pattern naming exactly what this may remove: `px-*` from a gutter, `mb-*` from the
* header, `py-*` from a band. Everything else on the element survives, which is what makes it safe to
* do this to a `<header>` carrying a border and a background as well.
*
* A target with nothing chosen is skipped entirely — see the note in the loop. That is what keeps a
* section's own padding, and the site's default gap, from disappearing while the panel is open.
*/
export type SpacingDraft = {
header?: { paddingX?: Steps; marginBottom?: Steps };
sections?: { paddingX?: Steps; paddingY?: Steps; gap?: Steps };
footer?: { paddingX?: Steps; marginTop?: Steps; gap?: Steps };
};
export type Steps = Partial<Record<'base' | 'md' | 'lg', number>>;
/** Mirrors `scale` in the site's layout-settings.ts. Same reason as themeProperties: a copy that shows
* a change before it is saved. If the live preview and the saved page disagree, this one is wrong. */
export const scaleClasses = (utility: string, steps: Steps | undefined): string => {
if (!steps) return '';
const prefix = { base: '', md: 'md:', lg: 'lg:' } as const;
return (['base', 'md', 'lg'] as const)
.filter((breakpoint) => typeof steps[breakpoint] === 'number')
.map((breakpoint) => `${prefix[breakpoint]}${utility}-${steps[breakpoint]}`)
.join(' ');
};
/** One selector, the utility it owns, and the classes to put there. */
const TARGETS = [
{ selector: '[data-gutter="header"]', utility: 'px', read: (s: SpacingDraft) => s.header?.paddingX },
{ selector: '[data-gutter="sections"]', utility: 'px', read: (s: SpacingDraft) => s.sections?.paddingX },
{ selector: '[data-gutter="sections"]', utility: 'py', read: (s: SpacingDraft) => s.sections?.paddingY },
{ selector: '[data-gutter="footer"]', utility: 'px', read: (s: SpacingDraft) => s.footer?.paddingX },
{ selector: '[data-space="header"]', utility: 'mb', read: (s: SpacingDraft) => s.header?.marginBottom },
{ selector: '[data-space="footer"]', utility: 'mt', read: (s: SpacingDraft) => s.footer?.marginTop },
{ selector: '[data-gap="sections"]', utility: 'gap', read: (s: SpacingDraft) => s.sections?.gap },
{ selector: '[data-gap="footer"]', utility: 'gap', read: (s: SpacingDraft) => s.footer?.gap },
] as const;
export function applySpacing(document: Document, spacing: SpacingDraft): () => void {
const touched: Array<[HTMLElement, string]> = [];
for (const target of TARGETS) {
const pattern = new RegExp(`^(?:md:|lg:)?${target.utility}-\\d+$`);
const classes = scaleClasses(target.utility, target.read(spacing)).split(/\s+/).filter(Boolean);
/*
Nothing chosen means DON'T TOUCH IT, rather than strip it.
Stripping was the old rule and it lied twice: a section's own `px-*` vanished while the panel was
open, and a band with no chosen gap lost the site's default `gap-8` — so the preview showed a
layout the page would never render. What the panel is drafting is an override, and an override
that has not been set is not an instruction to remove anything.
*/
if (!classes.length) continue;
for (const element of document.querySelectorAll<HTMLElement>(target.selector)) {
touched.push([element, element.className]);
const kept = element.className.split(/\s+/).filter((name) => name && !pattern.test(name));
element.className = [...kept, ...classes].join(' ');
}
}
return () => {
// Backwards: a section is touched twice, once per axis, so the FIRST record is the original and
// the second is the state after the x pass. Restoring in order would leave the intermediate one.
for (let index = touched.length - 1; index >= 0; index -= 1) {
const [element, before] = touched[index]!;
element.className = before;
}
};
}
/**
* The chosen type sizes, applied to a rendered page before they are saved.
*
* ── A stylesheet, not classes and not properties ──
*
* Colours and families are custom properties on `<html>`, which the panel can set directly. A size is
* three values, one per breakpoint, so it has to carry media queries — and only a stylesheet can. The
* site renders exactly this element, marked `data-font-sizes`; this finds it or makes one.
*
* The cleanup restores what was there, including removing an element the site had not rendered — which
* is the ordinary case, since it renders none until something is chosen.
*/
export function applyFontSizes(document: Document, css: string): () => void {
const existing = document.querySelector<HTMLStyleElement>('style[data-font-sizes]');
const element = existing ?? document.createElement('style');
const before = existing?.textContent ?? null;
if (!existing) {
element.setAttribute('data-font-sizes', '');
document.head.append(element);
}
element.textContent = css;
return () => {
if (before === null) element.remove();
else element.textContent = before;
};
}