Revised checkbox-tree functions and related
This commit is contained in:
Vendored
+310
@@ -0,0 +1,310 @@
|
||||
// Adapts pure state codecs to CheckboxTree's rendered node model and URL format.
|
||||
import {
|
||||
PATH_DELIMITER,
|
||||
SERIALIZATION_VERSION,
|
||||
asManifestSource as asManifest,
|
||||
assertStateEncoding,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
decodeAdaptiveBitset,
|
||||
decodeDenseBitset,
|
||||
encodeAdaptiveBitset,
|
||||
encodeDenseBitset,
|
||||
maximumEncodedLength
|
||||
} from './state-codec.js';
|
||||
|
||||
export function validateManifest(manifest, tree) {
|
||||
const normalized = asManifest(manifest);
|
||||
const treePaths = new Set(tree.nodesByPath.keys());
|
||||
return {
|
||||
valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)),
|
||||
unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)),
|
||||
missingPaths: normalized.slots
|
||||
.filter(slot => !slot.deleted && !treePaths.has(slot.path))
|
||||
.map(slot => slot.path)
|
||||
};
|
||||
}
|
||||
|
||||
export function validateManifestEvolution(previous, next) {
|
||||
const oldManifest = asManifest(previous);
|
||||
const newManifest = asManifest(next);
|
||||
const errors = [];
|
||||
|
||||
if (newManifest.slots.length < oldManifest.slots.length) {
|
||||
errors.push('Manifest slots cannot be removed; use tombstones.');
|
||||
}
|
||||
oldManifest.slots.forEach((oldSlot, index) => {
|
||||
const newSlot = newManifest.slots[index];
|
||||
if (!newSlot) return;
|
||||
if (oldSlot.deleted && !newSlot.deleted) {
|
||||
errors.push(`Tombstoned slot ${index} cannot be reused.`);
|
||||
}
|
||||
if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) {
|
||||
errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`);
|
||||
}
|
||||
});
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
function collectTreeValues(tree, manifest) {
|
||||
const normalized = asManifest(manifest);
|
||||
const checkedValues = new Map();
|
||||
const expandedValues = new Map();
|
||||
|
||||
for (const [path, node] of tree.nodesByPath) {
|
||||
const slot = normalized.pathToSlot.get(path);
|
||||
if (slot === undefined) continue;
|
||||
const item = tree.itemForNode(node.id);
|
||||
const checkbox = item && tree.directCheckbox(item);
|
||||
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
||||
checkedValues.set(slot, Boolean(checkbox?.checked));
|
||||
expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true');
|
||||
}
|
||||
|
||||
return { normalized, checkedValues, expandedValues };
|
||||
}
|
||||
|
||||
function nodeState(tree, node) {
|
||||
const item = tree.itemForNode(node.id);
|
||||
const checkbox = item && tree.directCheckbox(item);
|
||||
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
||||
return {
|
||||
checked: Boolean(checkbox?.checked),
|
||||
hasChecked: Boolean(checkbox),
|
||||
expanded: toggle?.getAttribute('aria-expanded') === 'true',
|
||||
hasExpanded: Boolean(toggle)
|
||||
};
|
||||
}
|
||||
|
||||
function encodeTieredState(tree, normalized) {
|
||||
const checkedByDepth = new Map();
|
||||
const expandedByDepth = new Map();
|
||||
const statesByPath = new Map();
|
||||
|
||||
for (const [path, node] of tree.nodesByPath) {
|
||||
const slot = normalized.pathToSlot.get(path);
|
||||
if (slot === undefined) continue;
|
||||
const depth = path.split(PATH_DELIMITER).length;
|
||||
const parentPath = path.includes(PATH_DELIMITER)
|
||||
? path.slice(0, path.lastIndexOf(PATH_DELIMITER))
|
||||
: null;
|
||||
const parent = statesByPath.get(parentPath) ?? {
|
||||
checked: false,
|
||||
hasChecked: false,
|
||||
expanded: false,
|
||||
hasExpanded: false
|
||||
};
|
||||
const current = nodeState(tree, node);
|
||||
if (current.hasChecked) {
|
||||
checkedByDepth.set(depth, checkedByDepth.get(depth) ?? new Map());
|
||||
checkedByDepth.get(depth).set(slot, current.checked !== (parent.hasChecked ? parent.checked : false));
|
||||
}
|
||||
if (current.hasExpanded) {
|
||||
expandedByDepth.set(depth, expandedByDepth.get(depth) ?? new Map());
|
||||
expandedByDepth.get(depth).set(slot, current.expanded);
|
||||
}
|
||||
statesByPath.set(path, current);
|
||||
}
|
||||
|
||||
const fields = new Map([['n', String(normalized.slots.length)]]);
|
||||
for (const [depth, values] of checkedByDepth) {
|
||||
const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length));
|
||||
if (encoded) fields.set(`c${depth}`, encoded);
|
||||
}
|
||||
for (const [depth, values] of expandedByDepth) {
|
||||
const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length));
|
||||
if (encoded) fields.set(`x${depth}`, encoded);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function encodeTreeState(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') {
|
||||
assertStateEncoding(encoding);
|
||||
const { normalized, checkedValues, expandedValues } = collectTreeValues(tree, manifest);
|
||||
|
||||
if (encoding === 'tiered') {
|
||||
return { version: SERIALIZATION_VERSION, encoding, fields: encodeTieredState(tree, normalized) };
|
||||
}
|
||||
|
||||
const encode = encoding === 'dense' ? encodeDenseBitset : encodeAdaptiveBitset;
|
||||
const checkedValue = bytesToBase64Url(encode(checkedValues, normalized.slots.length));
|
||||
const expandedValue = bytesToBase64Url(encode(expandedValues, normalized.slots.length));
|
||||
return {
|
||||
version: SERIALIZATION_VERSION,
|
||||
encoding,
|
||||
checked: checkedValue || null,
|
||||
expanded: expandedValue || null
|
||||
};
|
||||
}
|
||||
|
||||
function decodeField(params, name, normalized, encoding) {
|
||||
const maximumLength = encoding === 'dense'
|
||||
? Math.ceil(normalized.slots.length / 8)
|
||||
: maximumEncodedLength(normalized.slots.length);
|
||||
const bytes = base64UrlToBytes(params.get(name) ?? '', maximumLength);
|
||||
const decode = encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset;
|
||||
decode(bytes, normalized.slots.length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeTreeState(fragment, manifest, encoding = 'adaptive') {
|
||||
assertStateEncoding(encoding);
|
||||
const normalized = asManifest(manifest);
|
||||
const value = fragment.startsWith('#') ? fragment.slice(1) : fragment;
|
||||
const params = new URLSearchParams(value);
|
||||
if (params.get('v') !== String(SERIALIZATION_VERSION)) {
|
||||
return { applied: false, version: params.get('v'), encoding, warnings: ['Missing or unsupported serialization version.'] };
|
||||
}
|
||||
try {
|
||||
if (encoding === 'tiered') {
|
||||
if (params.has('c') || params.has('x')) {
|
||||
throw new Error('Tiered tree state contains non-tiered fields.');
|
||||
}
|
||||
const maximumDepth = normalized.slots.reduce((depth, slot) =>
|
||||
slot.deleted ? depth : Math.max(depth, slot.path.split(PATH_DELIMITER).length)
|
||||
, 0);
|
||||
for (const key of params.keys()) {
|
||||
const match = /^([cx])(\d+)$/.exec(key);
|
||||
if (match && (Number(match[2]) < 1 || Number(match[2]) > maximumDepth)) {
|
||||
throw new Error(`Tree state contains an invalid tier: ${key}`);
|
||||
}
|
||||
}
|
||||
const coverage = Number(params.get('n'));
|
||||
if (!Number.isSafeInteger(coverage) || coverage < 0 || coverage > normalized.slots.length) {
|
||||
throw new Error('Tiered tree state has invalid manifest coverage.');
|
||||
}
|
||||
const checkedTiers = new Map();
|
||||
const expandedTiers = new Map();
|
||||
for (let depth = 1; depth <= maximumDepth; depth++) {
|
||||
checkedTiers.set(depth, decodeField(params, `c${depth}`, normalized, 'adaptive'));
|
||||
expandedTiers.set(depth, decodeField(params, `x${depth}`, normalized, 'adaptive'));
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
version: SERIALIZATION_VERSION,
|
||||
encoding,
|
||||
coverage,
|
||||
checkedTiers,
|
||||
expandedTiers,
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
for (const key of params.keys()) {
|
||||
if (key === 'n' || /^[cx]\d+$/.test(key)) {
|
||||
throw new Error('Tree state contains tiered fields for a non-tiered encoding.');
|
||||
}
|
||||
}
|
||||
|
||||
const checked = decodeField(params, 'c', normalized, encoding);
|
||||
const expanded = decodeField(params, 'x', normalized, encoding);
|
||||
return {
|
||||
applied: true,
|
||||
version: SERIALIZATION_VERSION,
|
||||
encoding,
|
||||
checked,
|
||||
expanded,
|
||||
warnings: []
|
||||
};
|
||||
} catch (error) {
|
||||
return { applied: false, version: SERIALIZATION_VERSION, encoding, warnings: [error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTreeState(tree, manifest, state) {
|
||||
if (!state?.applied) return false;
|
||||
const normalized = asManifest(manifest);
|
||||
if (state.encoding === 'tiered') return applyTieredTreeState(tree, normalized, state);
|
||||
let isChecked;
|
||||
let isExpanded;
|
||||
try {
|
||||
const decode = state.encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset;
|
||||
isChecked = decode(state.checked, normalized.slots.length);
|
||||
isExpanded = decode(state.expanded, normalized.slots.length);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const [path, node] of tree.nodesByPath) {
|
||||
const slot = normalized.pathToSlot.get(path);
|
||||
if (slot === undefined) continue;
|
||||
const item = tree.itemForNode(node.id);
|
||||
const checkbox = item && tree.directCheckbox(item);
|
||||
if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot);
|
||||
tree.setExpanded(item, isExpanded(slot));
|
||||
}
|
||||
tree.updateAllParentCheckboxes();
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyTieredTreeState(tree, normalized, state) {
|
||||
const resolvedByPath = new Map();
|
||||
const checkedDecoders = new Map();
|
||||
const expandedDecoders = new Map();
|
||||
try {
|
||||
for (const [depth, bytes] of state.checkedTiers) {
|
||||
checkedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length));
|
||||
}
|
||||
for (const [depth, bytes] of state.expandedTiers) {
|
||||
expandedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length));
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, node] of tree.nodesByPath) {
|
||||
const slot = normalized.pathToSlot.get(path);
|
||||
if (slot === undefined) continue;
|
||||
const depth = path.split(PATH_DELIMITER).length;
|
||||
const parentPath = path.includes(PATH_DELIMITER)
|
||||
? path.slice(0, path.lastIndexOf(PATH_DELIMITER))
|
||||
: null;
|
||||
const parent = resolvedByPath.get(parentPath) ?? {
|
||||
checked: false,
|
||||
hasChecked: false,
|
||||
expanded: false,
|
||||
hasExpanded: false
|
||||
};
|
||||
const item = tree.itemForNode(node.id);
|
||||
const checkbox = item && tree.directCheckbox(item);
|
||||
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
|
||||
const isCovered = slot < state.coverage;
|
||||
const checked = checkbox && isCovered
|
||||
? (parent.hasChecked ? parent.checked : false) !== checkedDecoders.get(depth)(slot)
|
||||
: false;
|
||||
const expanded = Boolean(toggle && isCovered && expandedDecoders.get(depth)(slot));
|
||||
if (checkbox && !checkbox.disabled) checkbox.checked = checked;
|
||||
tree.setExpanded(item, expanded);
|
||||
resolvedByPath.set(path, {
|
||||
checked,
|
||||
hasChecked: Boolean(checkbox),
|
||||
expanded,
|
||||
hasExpanded: Boolean(toggle)
|
||||
});
|
||||
}
|
||||
tree.updateAllParentCheckboxes();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function serializeStateToFragment(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') {
|
||||
const state = encodeTreeState(tree, manifest, encoding);
|
||||
const params = new URLSearchParams({ v: String(state.version) });
|
||||
if (state.encoding === 'tiered') {
|
||||
for (const [name, value] of state.fields) params.set(name, value);
|
||||
} else {
|
||||
if (state.checked) params.set('c', state.checked);
|
||||
if (state.expanded) params.set('x', state.expanded);
|
||||
}
|
||||
return `#${params}`;
|
||||
}
|
||||
|
||||
export function restoreStateFromLocation(
|
||||
tree,
|
||||
manifest,
|
||||
location = window.location,
|
||||
encoding = tree.options?.stateEncoding ?? 'adaptive'
|
||||
) {
|
||||
const state = decodeTreeState(location.hash ?? '', manifest, encoding);
|
||||
if (state.applied) applyTreeState(tree, manifest, state);
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user