C1: HtmlBlock's PURIFY_CONFIG omitted 'style' from ALLOWED_ATTR, so the toolbar colour picker added in this branch was silently deleted by DOMPurify -- issue #2 was regressed, not fixed. Adds style/id plus table tags, with tests pinning the markup path in both render and toHtml. I3: PagesPanel's three confirmation states were not mutually exclusive; cancelling delete revealed an unbidden reset prompt on a destructive action. I5: orphan repair logged at console.warn, which the new console buffer cannot see -- the reporter would never capture the most diagnostic signal for the still-unreproduced drop bug. Also aligns useWhpApi's initial-load failure handling with loadState's fallback. I7: corrects comments (and the design spec) that asserted an orphan "renders somewhere on the canvas", which a mid-plan audit disproved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
125 lines
5.4 KiB
TypeScript
125 lines
5.4 KiB
TypeScript
/**
|
|
* Editor-state integrity: every node must be reachable from ROOT.
|
|
*
|
|
* A node that exists in `SerializedNodes` but appears in no parent's `nodes`
|
|
* or `linkedNodes` list is invisible to the Layers tree AND to Craft's own
|
|
* selection machinery -- but not because it "renders but can't be selected".
|
|
* Craft.js's `<Frame>` only instantiates nodes it can actually walk to via
|
|
* `data.nodes`/`linkedNodes` starting from ROOT, so an unreachable node is
|
|
* never rendered at all: it doesn't appear on the canvas, it's just inert
|
|
* data sitting in the serialized state. Reachability is computed from the
|
|
* PARENT'S child lists, not from each node's own `parent` pointer: a stale
|
|
* `parent: 'ROOT'` on a node ROOT never lists is precisely the broken case
|
|
* we're looking for.
|
|
*
|
|
* This is a DIFFERENT mechanism from the originally-reported symptom -- a
|
|
* *visible* element on the canvas that can't be selected or deleted. That
|
|
* symptom requires the node to be both rendered AND excluded from Craft's
|
|
* selection/interaction machinery, which is not what an unreachable-from-
|
|
* ROOT node produces (it isn't rendered at all). This repair fixes the
|
|
* "node is present in state but invisible/unrecoverable" case; the
|
|
* originally-reported "visible but unselectable" case is still
|
|
* unreproduced and is presumed to be a different bug.
|
|
*
|
|
* Pure functions over serialized state -- no React, no Craft instance.
|
|
*/
|
|
|
|
const ROOT_ID = 'ROOT';
|
|
|
|
function childIdsOf(node: any): string[] {
|
|
const nodes: string[] = Array.isArray(node?.nodes) ? node.nodes : [];
|
|
const linked: string[] = node?.linkedNodes ? Object.values(node.linkedNodes) : [];
|
|
return [...nodes, ...linked];
|
|
}
|
|
|
|
/** Ids of nodes present in `nodes` whose parent chain does not reach 'ROOT'. */
|
|
export function findUnreachableNodeIds(nodes: Record<string, any>): string[] {
|
|
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return [];
|
|
|
|
const reachable = new Set<string>([ROOT_ID]);
|
|
const queue: string[] = [ROOT_ID];
|
|
|
|
while (queue.length > 0) {
|
|
const id = queue.shift()!;
|
|
for (const childId of childIdsOf(nodes[id])) {
|
|
// The `reachable` guard also terminates on a cycle among real nodes.
|
|
if (typeof childId === 'string' && nodes[childId] && !reachable.has(childId)) {
|
|
reachable.add(childId);
|
|
queue.push(childId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Object.keys(nodes).filter((id) => !reachable.has(id));
|
|
}
|
|
|
|
/** Reattach every unreachable node to the end of ROOT.nodes. Returns the
|
|
* possibly-rewritten serialized state and the ids that were moved. Never
|
|
* throws: malformed input comes back unchanged with an empty `repaired`.
|
|
*
|
|
* Invariant: for any input that parses and has a ROOT, calling
|
|
* `findUnreachableNodeIds` on the returned `state` (parsed) always yields
|
|
* `[]` -- there is no orphan configuration this leaves half-repaired. */
|
|
export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] } {
|
|
let nodes: Record<string, any>;
|
|
try {
|
|
nodes = JSON.parse(serialized);
|
|
} catch {
|
|
// A page must never fail to load because the repair pass couldn't parse
|
|
// it -- hand the original string straight back to deserialize().
|
|
return { state: serialized, repaired: [] };
|
|
}
|
|
|
|
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) {
|
|
return { state: serialized, repaired: [] };
|
|
}
|
|
|
|
let unreachable = findUnreachableNodeIds(nodes);
|
|
if (unreachable.length === 0) return { state: serialized, repaired: [] };
|
|
|
|
if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = [];
|
|
const repaired: string[] = [];
|
|
|
|
// Loop because a single pass can leave orphan CYCLES untouched: if every
|
|
// member of a cluster points only at other members of that same cluster,
|
|
// none of them has a parent pointing "out", so nothing qualifies as a top
|
|
// and a one-shot pass would report `repaired: []` while the cluster is
|
|
// still unreachable. Each iteration re-derives `unreachable` from the
|
|
// current (partially repaired) state and terminates once it's empty --
|
|
// this is what makes the invariant hold rather than just being hoped for.
|
|
while (unreachable.length > 0) {
|
|
const orphanSet = new Set(unreachable);
|
|
|
|
// Reattach the TOP of each orphan subtree: a node whose `parent` points
|
|
// outside the current orphan set (to something real, to nothing, or is
|
|
// null). Its existing child list carries the rest of its subtree along
|
|
// for free once BFS can reach it again.
|
|
let tops = unreachable.filter((id) => {
|
|
const parent = nodes[id]?.parent;
|
|
return !(typeof parent === 'string' && orphanSet.has(parent));
|
|
});
|
|
|
|
// No such node exists only when every remaining orphan's `parent`
|
|
// points at another orphan -- i.e. a closed cycle (2+ nodes referencing
|
|
// only each other). There is no legitimate "outside" anchor to prefer,
|
|
// so break the cycle by force-reattaching one representative member
|
|
// (first in iteration order, for determinism). Because a cycle is
|
|
// strongly connected via the actual nodes/linkedNodes edges, reattaching
|
|
// any single member pulls the rest of that cycle in on the next
|
|
// `findUnreachableNodeIds` pass without touching their `parent` fields.
|
|
if (tops.length === 0) {
|
|
tops = [unreachable[0]];
|
|
}
|
|
|
|
for (const id of tops) {
|
|
nodes[id].parent = ROOT_ID;
|
|
nodes[ROOT_ID].nodes.push(id);
|
|
repaired.push(id);
|
|
}
|
|
|
|
unreachable = findUnreachableNodeIds(nodes);
|
|
}
|
|
|
|
return { state: JSON.stringify(nodes), repaired };
|
|
}
|