This commit is contained in:
127
src/lib/ui/composition.js
Normal file
127
src/lib/ui/composition.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* What a page is made of, and how a user's changes are folded into it.
|
||||
*
|
||||
* A page registers its own composition — the nodes it ships with, in order —
|
||||
* and this module holds the table. The engine reads the table; it never learns
|
||||
* a page name, and there is no switch here that would have to grow a case per
|
||||
* surface. Registering is how a page opts in, exactly as `registerNodeType` is
|
||||
* how a component does.
|
||||
*
|
||||
* The merge is the other half:
|
||||
*
|
||||
* built-in composition what the application ships
|
||||
* ⊕ saved user patch the person's own changes, persisted
|
||||
* ⊕ preview patch what they are trying, not yet saved
|
||||
* = the tree that renders
|
||||
*
|
||||
* Both patches are **lists of operations**, replayed onto a freshly computed
|
||||
* base. That is what makes a saved layout survive a release: a stored tree
|
||||
* would be a photograph of the page on the day it was saved, and every
|
||||
* improvement afterwards would be invisible to whoever had customised it.
|
||||
*
|
||||
* Skill sections are deliberately **not** merged here. A skill's `ui:` block is
|
||||
* still rendered by `SkillSurface`, exactly as it is today, and a surface is
|
||||
* simply one of the nodes a page composes. That keeps the existing Board-skill
|
||||
* behaviour byte-for-byte unchanged while still putting it in the tree, where
|
||||
* it can be hidden and reordered like anything else.
|
||||
*/
|
||||
|
||||
import { makeNode } from './node';
|
||||
import { applyPatch } from './patch';
|
||||
import { nodeRegistry } from './registry';
|
||||
|
||||
/** @type {Map<string, any[]>} */
|
||||
const compositions = new Map();
|
||||
|
||||
/**
|
||||
* A container node's placement, as its own registration declared it.
|
||||
*
|
||||
* Read off `props` rather than from a field the engine knows about, so the
|
||||
* composition layer needs no concept of what a placement is — only that a node
|
||||
* may name one and that skill sections are grouped by the same name.
|
||||
*/
|
||||
const placementOf = (node) => String(node?.props?.placement || '');
|
||||
|
||||
/**
|
||||
* The composition with each slot's skill sections hung underneath it.
|
||||
*
|
||||
* Done here, before any patch is replayed, so a person's saved operations act
|
||||
* on the same tree they were made against — including the skill sections. A
|
||||
* patch that hides a Board card keeps working; a patch naming a card whose
|
||||
* skill has since been switched off is skipped, like any other stale operation.
|
||||
*/
|
||||
function attachSkillNodes(base, byPlacement) {
|
||||
if (!byPlacement) return base;
|
||||
return base.map((node) => {
|
||||
const placement = placementOf(node);
|
||||
const children = placement ? byPlacement[placement] : null;
|
||||
if (!children?.length) return node;
|
||||
return { ...node, children };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the nodes a page ships with.
|
||||
*
|
||||
* Called once, at module scope, beside the page it describes — so a page and
|
||||
* its composition move together and neither can be deployed without the other.
|
||||
* Re-registering replaces, which is what a hot module reload needs; a duplicate
|
||||
* is not an error the way a duplicate *type* is, because the second call is the
|
||||
* same page saying the same thing again.
|
||||
*/
|
||||
export function registerPageComposition(page, nodes) {
|
||||
const key = String(page ?? '').trim();
|
||||
if (!key) throw new Error('registerPageComposition: a composition needs a page.');
|
||||
compositions.set(key, (nodes || []).map((node) => makeNode({ origin: 'builtin', ...node })));
|
||||
return compositions.get(key);
|
||||
}
|
||||
|
||||
/** The nodes a page ships with, or an empty list for a page that has not opted in. */
|
||||
export const compositionFor = (page) => compositions.get(String(page ?? '').trim()) || [];
|
||||
|
||||
/** Whether this page composes through the node system yet. */
|
||||
export const hasComposition = (page) => compositions.has(String(page ?? '').trim());
|
||||
|
||||
/** Every page that has registered. Used by tests and, later, by the editor. */
|
||||
export const composedPages = () => [...compositions.keys()];
|
||||
|
||||
/** Forget everything. Tests only. */
|
||||
export const resetCompositions = () => compositions.clear();
|
||||
|
||||
/**
|
||||
* The tree to render for a page.
|
||||
*
|
||||
* `skipped` carries the operations that no longer apply — a saved change naming
|
||||
* a node a release has since removed. They are reported rather than thrown:
|
||||
* that is not the user's mistake, and it must not cost them the rest of their
|
||||
* layout.
|
||||
*/
|
||||
export function composePage(page, {
|
||||
patch = null, preview = null, registry = nodeRegistry, role = null,
|
||||
/**
|
||||
* The sections this page's definitions contribute, grouped by placement.
|
||||
*
|
||||
* Passed in rather than read here, because resolving them needs the account's
|
||||
* custom skills and disabled list — React state, which this module must stay
|
||||
* free of to remain a pure function two callers can trust equally.
|
||||
*/
|
||||
skillNodes = null,
|
||||
} = {}) {
|
||||
const base = attachSkillNodes(compositionFor(page), skillNodes);
|
||||
const context = { registry, role };
|
||||
const skipped = [];
|
||||
|
||||
let tree = base;
|
||||
|
||||
/* Saved first, then preview. Order matters: a preview is composed against
|
||||
what the person has already saved, so what they see while deciding is what
|
||||
they will get if they keep it. */
|
||||
for (const layer of [patch, preview]) {
|
||||
if (!layer?.ops?.length) continue;
|
||||
const result = applyPatch(tree, layer, context);
|
||||
tree = result.tree;
|
||||
skipped.push(...result.skipped);
|
||||
}
|
||||
|
||||
return { tree, skipped };
|
||||
}
|
||||
Reference in New Issue
Block a user