Every column gets Add component; every component in one gets Edit and Delete, drawn ON the rendered thing rather than replacing it. An editor that swaps content for a placeholder is showing you the editor, not the page — and free text with nothing in it renders as nothing, so the outline is the only thing saying it is there. Add component opens the editor STRAIGHT AWAY. Free Text is the only kind, and one option is not a choice: a picker offering it would be a click asking to be got past. The picker arrives with the second kind, which is when it starts meaning something. The add button is quieter than Add section. This is the inner loop, and a control as loud as the one that adds a whole band would make every column shout. ── The dialog, and the limitation worth naming ── Editing happens in a modal, not in the page. In-place is the obvious idea and a much bigger one: the toolbar would have to live in a document the site owns, the site's typography would fight it, and every keystroke would cross into another document. The dialog is where a working editor already exists, and it costs a modal instead of a rewrite. So what you type is not laid out in the page until you save. The preview reloads immediately after, so the loop is short — but it is a loop, and direct manipulation is what a page builder eventually wants. The editor is keyed on the stored html, so opening a different component rebuilds it rather than reusing one still holding the last component's document. Empty text saves: clearing a paragraph is deleting its words, not abandoning the edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
430 lines
16 KiB
TypeScript
430 lines
16 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; content?: { html?: string } };
|
|
export type CanvasSection = { id: number; name: string; layout: 'one' | 'two'; components: CanvasComponent[] };
|
|
|
|
export type Canvas = {
|
|
sections: CanvasSection[];
|
|
onAddSection: () => void;
|
|
onDeleteSection: (section: CanvasSection) => void;
|
|
/** A column wants something in it. */
|
|
onAddComponent: (section: CanvasSection, column: number) => void;
|
|
/** Something already in a column was pressed. */
|
|
onEditComponent: (component: CanvasComponent) => void;
|
|
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)[] = [];
|
|
|
|
/*
|
|
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((columnElement, columnIndex) => {
|
|
const was = columnElement.style.outline;
|
|
const wasOffset = columnElement.style.outlineOffset;
|
|
columnElement.style.outline = `1px dashed color-mix(in srgb, ${ACCENT} 45%, transparent)`;
|
|
columnElement.style.outlineOffset = '-5px';
|
|
undo.push(() => {
|
|
columnElement.style.outline = was;
|
|
columnElement.style.outlineOffset = wasOffset;
|
|
});
|
|
|
|
/*
|
|
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;
|
|
const wasOutline = node.style.outline;
|
|
node.style.position = 'relative';
|
|
node.style.outline = `1px dotted color-mix(in srgb, ${ACCENT} 60%, transparent)`;
|
|
undo.push(() => {
|
|
node.style.position = wasPosition;
|
|
node.style.outline = wasOutline;
|
|
});
|
|
|
|
const tools = document.createElement('div');
|
|
tools.setAttribute(MARK, 'component-tools');
|
|
style(tools, [
|
|
'position:absolute',
|
|
'top:0',
|
|
'right:0',
|
|
'display:flex',
|
|
'gap:0.5rem',
|
|
'padding:0.125rem 0.25rem',
|
|
'font-size:0.625rem',
|
|
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
`color:${ACCENT}`,
|
|
'background:var(--site-bg, #fff)',
|
|
]);
|
|
|
|
for (const [label, run] of [
|
|
['Edit', () => canvas.onEditComponent(component)],
|
|
['Delete', () => canvas.onDeleteComponent(component)],
|
|
] as const) {
|
|
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 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();
|
|
};
|
|
}
|