diff --git a/web/canvas.ts b/web/canvas.ts index def133a..8fe0584 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -112,369 +112,74 @@ const style = (element: HTMLElement, rules: string[]) => { * 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. */ +/** + * 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. + * + * ── 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)[] = []; - /* - 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('[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(`[data-section-id="${section.id}"]`); + const element = document.querySelector(`[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('[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) { + // 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; - 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.removeAttribute('data-fofos-component')); + + // A block the panel has no editor for is left alone: a click that opens nothing reads as a + // failure, which is worse than a click that does nothing. See canEditComponent. + if (canvas.canEditComponent && !canvas.canEditComponent(component)) continue; + + 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.style.position = wasPosition; - node.removeAttribute('data-fofos-component'); + node.removeEventListener('click', open); + node.style.cursor = wasCursor; }); - - 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('[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(); }; } diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 5b4965c..b5a887b 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -4,7 +4,7 @@ import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; import { usePreviewable, useViewMode } from '../ViewMode'; -import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas'; +import { paintSelection, type CanvasComponent, type CanvasSection } from '../canvas'; /* What the Spacing dialog calls a block, by kind. @@ -163,7 +163,7 @@ export const PageBuilderPanel = () => { const onDocument = useCallback( (frameDocument: Document) => { frameDoc.current = frameDocument; - return paintCanvas(frameDocument, { + return paintSelection(frameDocument, { sections, onAddSection: () => handlersRef.current.add(), onDeleteSection: (section) => handlersRef.current.remove(section.id),