Inside the band, whole width of main, or whole page width. The negatives are gone from the padding select — padding is padding again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
411 lines
18 KiB
TypeScript
411 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;
|
|
/** `main`, `page` or null — how wide it is allowed to be. See the site's schema. */
|
|
width?: 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; url?: string };
|
|
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;
|
|
};
|
|
}
|