The room between a band's columns: gap-8 in a two-column section, gap-x-24 gap-y-10 in the footer. Both were written into the template that drew them. The canvas now SKIPS a utility nothing has chosen instead of stripping it. The old rule lied twice — a section's own padding vanished while the panel was open, and a band with no chosen gap lost the site's default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
699 lines
29 KiB
TypeScript
699 lines
29 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;
|
|
|
|
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;
|
|
};
|
|
}
|