Two things the guides made visible the moment they existed. THE SKELETON CROSSED THE LINES. Add section was appended to `main`, which is the full width of the page — the content column is what holds it to its measure. So the drop zone ran edge to edge and straight past the guides, describing a place a section could go that no section will ever occupy. It goes in the column now, falling back to main for a page that opts out of the measure, where the column genuinely is the page. The section outline had the same fault in miniature: `outline-offset: 4px` puts it OUTSIDE the element, so a band filling the column drew its edges past the guides. Inset now. Editor chrome that overflows the thing it describes is chrome that lies about it. THE VIEW WAS LEFT-ANCHORED. Narrowing the panel showed the left margin and the start of the content — the least informative part of a centred layout and never the part anybody is looking at. The scroller now starts in the middle, because the page is centred on a real screen and the preview should be too. On the scroller, not a transform. A transform is the scaling this just stopped doing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
12 KiB
TypeScript
324 lines
12 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';
|
|
|
|
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two' };
|
|
|
|
export type Canvas = {
|
|
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.
|
|
*/
|
|
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)[] = [];
|
|
|
|
/*
|
|
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;
|
|
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}`;
|
|
// INSET. At `4px` the outline sat outside the element, so a band that fills the content column drew
|
|
// its edges past the measure — the guides said the column ended and the skeleton crossed them.
|
|
// Editor chrome that overflows the thing it is describing is chrome that lies about it.
|
|
element.style.outlineOffset = '-1px';
|
|
// 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 = '-5px';
|
|
undo.push(() => {
|
|
column.style.outline = was;
|
|
column.style.outlineOffset = wasOffset;
|
|
});
|
|
});
|
|
}
|
|
|
|
/*
|
|
The measure, drawn as two lines.
|
|
|
|
The page is held to `max-w-7xl` and centred, so on a wide screen the content stops well short of the
|
|
window and there is nothing to say where. That is invisible until something is placed against it and
|
|
turns out to be in the wrong place, which is the moment it is expensive.
|
|
|
|
`position:fixed` inside the frame, so they stay put while the page scrolls — a guide that scrolls
|
|
away is a guide for the top of the page only. Redrawn on resize, because the frame's width changes
|
|
when the device does.
|
|
*/
|
|
const column = root.querySelector<HTMLElement>('[data-content-column]');
|
|
if (column) {
|
|
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, [
|
|
"position:fixed",
|
|
"top:0",
|
|
"bottom:0",
|
|
`left:${Math.round(x)}px`,
|
|
"width:1px",
|
|
`background:color-mix(in srgb, ${ACCENT} 35%, transparent)`,
|
|
// Behind nothing and in front of nothing that matters: 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();
|
|
const frameWindow = document.defaultView;
|
|
frameWindow?.addEventListener("resize", place);
|
|
undo.push(() => {
|
|
frameWindow?.removeEventListener("resize", place);
|
|
for (const guide of guides) guide.remove();
|
|
});
|
|
}
|
|
|
|
// ── Add section, always last ─────────────────────────────────────────────────────────────────
|
|
/*
|
|
Add section goes inside the CONTENT COLUMN, not `main`.
|
|
|
|
`main` is the full width of the page; the column is what holds it to its measure. Appended to main,
|
|
the drop zone ran edge to edge and straight past the guides — describing a place a section could go
|
|
that no section will ever occupy.
|
|
|
|
Falls back to `main` for a page that opts out of the measure (`full` in Layout), where the column
|
|
genuinely is the page.
|
|
*/
|
|
const zoneParent = root.querySelector<HTMLElement>('[data-content-column]') ?? root;
|
|
|
|
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);
|
|
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();
|
|
};
|
|
}
|