diff --git a/web/canvas.ts b/web/canvas.ts index 6326f56..2cab013 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -42,12 +42,25 @@ 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' }; +/** + * 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; }; /** @@ -181,14 +194,107 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void { 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((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'; + element.querySelectorAll('[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(() => { - column.style.outline = was; - column.style.outlineOffset = wasOffset; + 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(); }); }); } diff --git a/web/sections/FreeTextDialog.tsx b/web/sections/FreeTextDialog.tsx new file mode 100644 index 0000000..436bfde --- /dev/null +++ b/web/sections/FreeTextDialog.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { RichText } from '../RichText'; + +/** + * Writing the words in a Free Text component. + * + * ── Why a dialog and not in-place on the page ── + * + * Editing inside the frame is the obvious idea and it is a much bigger one: the editor's toolbar would + * have to live in a document the site owns, the site's own typography would fight it, and every + * keystroke would cross into another document. The dialog is where the plugin already has a working + * editor, and it costs a modal instead of a rewrite. + * + * It is a real limitation and worth naming: 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 rather than direct + * manipulation, and direct manipulation is what a page builder eventually wants. + */ +export const FreeTextDialog = ({ + open, + busy, + initial, + onCancel, + onSave, +}: { + open: boolean; + busy: boolean; + /** What is stored today. Empty for a component that was only just added. */ + initial: string; + onCancel: () => void; + onSave: (html: string) => void; +}) => { + const [html, setHtml] = useState(initial); + + // Reset from the stored value on every open, so a cancelled edit is genuinely cancelled rather than + // waiting in the box for the next time somebody opens it. + useEffect(() => { + if (open) setHtml(initial); + }, [open, initial]); + + return ( + !next && onCancel()}> + + + Text + + + {/* Keyed on `initial`, so opening a DIFFERENT component rebuilds the editor rather than + reusing one still holding the last component's document. */} +
+ +
+ + + + {/* No disabled-when-empty here, unlike the section name: empty text is a legitimate thing to + save. Somebody clearing a paragraph is deleting its words, not abandoning the edit. */} + + +
+
+ ); +}; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index d4601aa..7837b88 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -4,8 +4,9 @@ import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; import { usePreviewable, useViewMode } from '../ViewMode'; -import { paintCanvas, type CanvasSection } from '../canvas'; +import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas'; import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; +import { FreeTextDialog } from './FreeTextDialog'; import { usePages, type Page } from '../usePages'; /** @@ -50,9 +51,19 @@ export const PageBuilderPanel = () => { so putting them in the dependency list would repaint on every render; through a ref they are always the current ones without being part of the identity. */ - const handlersRef = useRef<{ add: () => void; remove: (id: number) => void }>({ add: () => {}, remove: () => {} }); + const handlersRef = useRef<{ + add: () => void; + remove: (id: number) => void; + addComponent: (section: CanvasSection, column: number) => void; + editComponent: (component: CanvasComponent) => void; + removeComponent: (component: CanvasComponent) => void; + }>({ add: () => {}, remove: () => {}, addComponent: () => {}, editComponent: () => {}, removeComponent: () => {} }); const [adding, setAdding] = useState(false); const [creating, setCreating] = useState(false); + /** The component being written, and where a new one would go. Null when the dialog is shut. */ + const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>( + null, + ); /* Bumped after a write, and passed to SitePreview to remount the frame. @@ -86,6 +97,9 @@ export const PageBuilderPanel = () => { sections, onAddSection: () => handlersRef.current.add(), onDeleteSection: (section) => handlersRef.current.remove(section.id), + onAddComponent: (section, column) => handlersRef.current.addComponent(section, column), + onEditComponent: (component) => handlersRef.current.editComponent(component), + onDeleteComponent: (component) => handlersRef.current.removeComponent(component), }); }, // `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous @@ -162,7 +176,63 @@ export const PageBuilderPanel = () => { // Assigned on every render, after both functions above exist, so the ref always holds the current // pair without either being part of `onDocument`'s identity. - handlersRef.current = { add: () => setAdding(true), remove: (sectionId: number) => void deleteSection(sectionId) }; + /* + Free Text is the only kind, so Add component opens the editor straight away. + + No picker in between: one option is not a choice, and a dialog that offers it is a click asking to + be got past. The picker arrives with the second kind, which is the moment it starts meaning + something. + */ + const addComponent = (section: CanvasSection, column: number) => + setEditing({ id: null, sectionId: section.id, column, html: '' }); + + const editComponent = (component: CanvasComponent) => { + const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); + const stored = owner?.components.find((one) => one.id === component.id); + const html = typeof stored?.content?.html === 'string' ? stored.content.html : ''; + setEditing({ id: component.id, sectionId: owner?.id ?? 0, column: component.column, html }); + }; + + const saveText = async (html: string) => { + if (!page || !editing) return; + setCreating(true); + setError(null); + const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`; + try { + if (editing.id === null) { + await clientRef.current.post(base, { kind: 'free-text', column: editing.column, content: { html } }); + } else { + await clientRef.current.patch(`${base}/${editing.id}`, { content: { html } }); + } + setEditing(null); + setRendered((n) => n + 1); + } catch (failure) { + setError(readableError(failure)); + } finally { + setCreating(false); + } + }; + + const deleteComponent = async (component: CanvasComponent) => { + if (!page) return; + const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); + if (!owner) return; + setError(null); + try { + await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`); + setRendered((n) => n + 1); + } catch (failure) { + setError(readableError(failure)); + } + }; + + handlersRef.current = { + add: () => setAdding(true), + remove: (sectionId: number) => void deleteSection(sectionId), + addComponent, + editComponent, + removeComponent: (component) => void deleteComponent(component), + }; if (error) return
{error}
; if (!pages) return
Loading…
; @@ -192,6 +262,14 @@ export const PageBuilderPanel = () => { onCancel={() => setAdding(false)} onCreate={(values) => void createSection(values)} /> + + setEditing(null)} + onSave={(html) => void saveText(html)} + /> ); };