172 lines
6.3 KiB
JavaScript
172 lines
6.3 KiB
JavaScript
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 <UiEditingContext.Provider value={value}>{children}</UiEditingContext.Provider>;
|
|
}
|