import React from 'react'; import { useUiLayouts } from '@/lib/krowHooks'; import { useSkillSections } from '@/components/skills/SkillSurface'; import { skillNodesByPlacement } from '@/lib/ui/skillNodes'; import { composePage } from '@/lib/ui/composition'; import { applyOperation } from '@/lib/ui/operations'; import { clearOps, emptyPatch, popOp, pushOp } from '@/lib/ui/patch'; import { nodeRegistry } from '@/lib/ui/registry'; /** * Preview, Apply, and the line between them. * * Three states of a UI change live here, and keeping them apart is the whole * job of this module: * * - **Preview** is in React state. It is what the person is trying. It never * reaches the network, and a reload discards it. * - **Saved** is the person's own stored operation list, read from and * written to their account preferences. It survives a reload and a new * session. * - **Source** — the page's registered composition and the Markdown skill * files — is never written by anything here. Not on preview, not on apply. * * A proposed operation is validated *before* it becomes a preview, so a change * that could not be saved is never shown as though it could. Preview and apply * then run the identical merge through `composePage` and the identical * renderer, which is what makes a preview honest: there is no second code path * for the applied state that could disagree with it. */ const UiEditingContext = React.createContext(null); /** The editing session for the page around it. Null outside a provider. */ export const useUiEditing = () => React.useContext(UiEditingContext); export function UiEditingProvider({ page, role = null, registry = nodeRegistry, children }) { const { layouts, save, saving } = useUiLayouts(); /** * What the person is trying, not yet theirs. * * Held per page so that navigating away and back does not carry an * unfinished experiment onto a different surface. */ const [preview, setPreview] = React.useState(() => emptyPatch(page)); const [problems, setProblems] = React.useState([]); /* A page change is a new editing session. Anything unsaved was about the page that is no longer on screen. */ React.useEffect(() => { setPreview(emptyPatch(page)); setProblems([]); }, [page]); const saved = layouts[page] || emptyPatch(page); /** * What this page's definitions contribute, as nodes. * * Resolved here because it needs the account's custom skills and disabled * list, and handed to `composePage` so that module stays a pure function. * The result is that a Board skill's card is a node in the same tree as the * page's own sections — addressable by the same operations, and hidden or * moved by a patch rather than by editing the definition. */ const sections = useSkillSections(page); const skillNodes = React.useMemo(() => skillNodesByPlacement(sections), [sections]); /** * The tree on screen: what the application ships, with what the person saved, * with what they are trying, in that order. */ const composed = React.useMemo( () => composePage(page, { patch: saved, preview, registry, role, skillNodes }), [page, saved, preview, registry, role, skillNodes] ); /** * Try an operation. * * Validated against the tree as it currently stands — saved changes included * — so an operation is judged against what the person is actually looking at. * A refusal returns the reasons and changes nothing; there is no partially * applied preview. */ const propose = React.useCallback((op) => { const result = applyOperation(composed.tree, op, { registry, role }); if (!result.ok) { setProblems(result.problems); return result; } setProblems([]); setPreview((current) => pushOp(current, op)); return result; }, [composed.tree, registry, role]); /** Throw the experiment away. Nothing was stored, so nothing is undone. */ const discard = React.useCallback(() => { setPreview(emptyPatch(page)); setProblems([]); }, [page]); /** * Keep it. * * The preview's operations are appended to what was already saved and written * as one list. The preview is only cleared once the write resolves, so a * failed save leaves the person looking at the change they asked for rather * than watching it disappear with an error beside it. */ const apply = React.useCallback(async () => { if (!preview.ops.length) return { ok: true, saved }; const next = { ...saved, page, ops: [...saved.ops, ...preview.ops], updatedAt: new Date().toISOString() }; const result = await save(page, next); if (result?.persisted === false) { setProblems([{ at: null, message: result.error || 'That change could not be saved.' }]); return { ok: false, saved }; } setPreview(emptyPatch(page)); return { ok: true, saved: next }; }, [preview, saved, page, save]); /** * Undo one step. * * The most recent thing first: an unsaved operation if there is one, and only * then a saved one. Undoing a saved change is a write, because the saved list * is the record of what the person chose. */ const undo = React.useCallback(async () => { if (preview.ops.length) { setPreview((current) => popOp(current)); return { ok: true }; } if (!saved.ops.length) return { ok: true }; await save(page, popOp(saved)); return { ok: true }; }, [preview, saved, page, save]); /** Back to the page as the application ships it. Clears both tiers. */ const reset = React.useCallback(async () => { setPreview(emptyPatch(page)); setProblems([]); if (saved.ops.length) await save(page, clearOps(saved)); return { ok: true }; }, [page, saved, save]); const value = React.useMemo(() => ({ page, tree: composed.tree, /* Operations that no longer apply — a saved change naming a section a release has since removed. Surfaced so a page can say so quietly rather than leaving the person wondering why nothing happened. */ skipped: composed.skipped, saved, preview, problems, previewing: preview.ops.length > 0, customised: saved.ops.length > 0, saving, propose, discard, apply, undo, reset, }), [ page, composed, saved, preview, problems, saving, propose, discard, apply, undo, reset, ]); return {children}; }