80 lines
3.0 KiB
TypeScript
80 lines
3.0 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 -- it renders somewhere on the canvas but can't be
|
||
|
|
* selected or deleted, which is exactly the "dropped outside the page"
|
||
|
|
* report. 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.
|
||
|
|
*
|
||
|
|
* 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`. */
|
||
|
|
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: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
const unreachable = findUnreachableNodeIds(nodes);
|
||
|
|
if (unreachable.length === 0) return { state: serialized, repaired: [] };
|
||
|
|
|
||
|
|
// Reattach only the TOP of each orphan subtree. An orphan whose parent is
|
||
|
|
// itself an orphan keeps its existing parent and rides along.
|
||
|
|
const orphanSet = new Set(unreachable);
|
||
|
|
const tops = unreachable.filter((id) => {
|
||
|
|
const parent = nodes[id]?.parent;
|
||
|
|
return !(typeof parent === 'string' && orphanSet.has(parent));
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = [];
|
||
|
|
for (const id of tops) {
|
||
|
|
nodes[id].parent = ROOT_ID;
|
||
|
|
nodes[ROOT_ID].nodes.push(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
return { state: JSON.stringify(nodes), repaired: tops };
|
||
|
|
}
|