Revised checkbox-tree functions and related
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { getSortedKeys } from './sort.js';
|
||||
|
||||
export function expandFilePrefixLevel(root) {
|
||||
if (!root || typeof root !== 'object') {
|
||||
return root;
|
||||
}
|
||||
return transformNode(root);
|
||||
}
|
||||
|
||||
function transformNode(node) {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const hasIssueArrays = Object.prototype.hasOwnProperty.call(node, 'addressed') ||
|
||||
Object.prototype.hasOwnProperty.call(node, 'known');
|
||||
const childKeys = Object.keys(node).filter(key => key !== 'addressed' && key !== 'known');
|
||||
const transformedChildren = Object.fromEntries(
|
||||
childKeys.map(key => [key, transformNode(node[key])])
|
||||
);
|
||||
|
||||
if (!hasIssueArrays) {
|
||||
return transformedChildren;
|
||||
}
|
||||
|
||||
const addressed = Array.isArray(node.addressed) ? node.addressed : [];
|
||||
const known = Array.isArray(node.known) ? node.known : [];
|
||||
if (addressed.length + known.length === 0) {
|
||||
return childKeys.length > 0 ? transformedChildren : { addressed: [], known: [] };
|
||||
}
|
||||
|
||||
const groupedByPrefix = {};
|
||||
for (const [issueType, files] of [['addressed', addressed], ['known', known]]) {
|
||||
for (const fileName of files) {
|
||||
const prefix = getFilePrefix(fileName);
|
||||
groupedByPrefix[prefix] ??= { addressed: [], known: [] };
|
||||
groupedByPrefix[prefix][issueType].push(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
const transformedIssueFiles = groupHotfixPrefixes(groupedByPrefix);
|
||||
return childKeys.length === 0
|
||||
? transformedIssueFiles
|
||||
: mergeTreeNodes(transformedChildren, transformedIssueFiles);
|
||||
}
|
||||
|
||||
function groupHotfixPrefixes(groupedByPrefix) {
|
||||
const byFamily = {};
|
||||
for (const prefix of Object.keys(groupedByPrefix)) {
|
||||
const family = getReleaseFamily(prefix);
|
||||
byFamily[family] ??= [];
|
||||
byFamily[family].push(prefix);
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [family, members] of Object.entries(byFamily)) {
|
||||
const hasHotfixes = members.length > 1 || members.some(member => member !== family);
|
||||
if (!hasHotfixes && members[0] === family) {
|
||||
result[family] = groupedByPrefix[family];
|
||||
} else {
|
||||
result[family] = Object.fromEntries(
|
||||
members.map(member => [member, groupedByPrefix[member]])
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getReleaseFamily(prefix) {
|
||||
return String(prefix || '').replace(/-h\d+$/i, '');
|
||||
}
|
||||
|
||||
function getFilePrefix(fileName) {
|
||||
const baseName = String(fileName || '');
|
||||
const underscoreIndex = baseName.indexOf('_');
|
||||
if (underscoreIndex <= 0) {
|
||||
return baseName.replace(/\.json$/i, '') || 'unknown';
|
||||
}
|
||||
return baseName.slice(0, underscoreIndex);
|
||||
}
|
||||
|
||||
function mergeTreeNodes(left, right) {
|
||||
if (!isObjectNode(left)) return right;
|
||||
if (!isObjectNode(right)) return left;
|
||||
const merged = { ...left };
|
||||
|
||||
for (const [key, rightValue] of Object.entries(right)) {
|
||||
const leftValue = merged[key];
|
||||
if (key === 'addressed' || key === 'known') {
|
||||
const leftFiles = Array.isArray(leftValue) ? leftValue : [];
|
||||
const rightFiles = Array.isArray(rightValue) ? rightValue : [];
|
||||
merged[key] = Array.from(new Set([...leftFiles, ...rightFiles]));
|
||||
} else if (isObjectNode(leftValue) && isObjectNode(rightValue)) {
|
||||
merged[key] = mergeTreeNodes(leftValue, rightValue);
|
||||
} else {
|
||||
merged[key] = rightValue;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isObjectNode(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function toTreeNodes(value, path = []) {
|
||||
return getSortedKeys(value)
|
||||
.filter(key => key !== 'addressed' && key !== 'known')
|
||||
.map(key => {
|
||||
const childValue = value[key];
|
||||
const childPath = [...path, key];
|
||||
const isObject = childValue && typeof childValue === 'object';
|
||||
const hasFiles = isObject && ('addressed' in childValue || 'known' in childValue);
|
||||
const children = isObject ? toTreeNodes(childValue, childPath) : [];
|
||||
return {
|
||||
id: JSON.stringify(childPath),
|
||||
label: key,
|
||||
selectable: hasFiles || children.length > 0,
|
||||
metadata: {
|
||||
path: childPath,
|
||||
addressed: hasFiles && Array.isArray(childValue.addressed) ? childValue.addressed : [],
|
||||
known: hasFiles && Array.isArray(childValue.known) ? childValue.known : []
|
||||
},
|
||||
...(children.length > 0 ? { children } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function enumerateTreePaths(nodes) {
|
||||
const paths = [];
|
||||
function visit(children, parentPath = '') {
|
||||
for (const node of children) {
|
||||
const path = parentPath ? `${parentPath}>${node.id}` : node.id;
|
||||
paths.push(path);
|
||||
visit(node.children ?? [], path);
|
||||
}
|
||||
}
|
||||
visit(nodes);
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function buildProductTree(products) {
|
||||
return toTreeNodes(expandFilePrefixLevel(products));
|
||||
}
|
||||
+1
-148
@@ -1,6 +1,7 @@
|
||||
import { loadIssuesForCheckedPaths } from './issues.js';
|
||||
import { getCheckedFileRefs, initializeTree, renderProductTree, restoreTreeState } from './tree.js';
|
||||
import { applyIssueSearchFilter, getIssueTypeFilters, initializeUI } from './ui.js';
|
||||
import { expandFilePrefixLevel } from './product-tree-model.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeUI({ onSelectionChange: refreshIssuesForCurrentSelection });
|
||||
@@ -29,151 +30,3 @@ function loadProductTree() {
|
||||
})
|
||||
.catch(error => console.error('Error loading products:', error));
|
||||
}
|
||||
|
||||
function expandFilePrefixLevel(root) {
|
||||
if (!root || typeof root !== 'object') {
|
||||
return root;
|
||||
}
|
||||
|
||||
return transformNode(root);
|
||||
}
|
||||
|
||||
function transformNode(node) {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const hasIssueArrays = Object.prototype.hasOwnProperty.call(node, 'addressed') ||
|
||||
Object.prototype.hasOwnProperty.call(node, 'known');
|
||||
const childKeys = Object.keys(node).filter(key => key !== 'addressed' && key !== 'known');
|
||||
|
||||
const transformedChildren = {};
|
||||
childKeys.forEach(key => {
|
||||
transformedChildren[key] = transformNode(node[key]);
|
||||
});
|
||||
|
||||
if (!hasIssueArrays) {
|
||||
return transformedChildren;
|
||||
}
|
||||
|
||||
const addressed = Array.isArray(node.addressed) ? node.addressed : [];
|
||||
const known = Array.isArray(node.known) ? node.known : [];
|
||||
const allFiles = [...addressed, ...known];
|
||||
|
||||
if (allFiles.length === 0) {
|
||||
if (childKeys.length > 0) {
|
||||
return transformedChildren;
|
||||
}
|
||||
|
||||
return {
|
||||
addressed: [],
|
||||
known: []
|
||||
};
|
||||
}
|
||||
|
||||
const groupedByPrefix = {};
|
||||
|
||||
addressed.forEach(fileName => {
|
||||
const prefix = getFilePrefix(fileName);
|
||||
if (!groupedByPrefix[prefix]) {
|
||||
groupedByPrefix[prefix] = { addressed: [], known: [] };
|
||||
}
|
||||
groupedByPrefix[prefix].addressed.push(fileName);
|
||||
});
|
||||
|
||||
known.forEach(fileName => {
|
||||
const prefix = getFilePrefix(fileName);
|
||||
if (!groupedByPrefix[prefix]) {
|
||||
groupedByPrefix[prefix] = { addressed: [], known: [] };
|
||||
}
|
||||
groupedByPrefix[prefix].known.push(fileName);
|
||||
});
|
||||
|
||||
const transformedIssueFiles = groupHotfixPrefixes(groupedByPrefix);
|
||||
if (childKeys.length === 0) {
|
||||
return transformedIssueFiles;
|
||||
}
|
||||
|
||||
return mergeTreeNodes(transformedChildren, transformedIssueFiles);
|
||||
}
|
||||
|
||||
function groupHotfixPrefixes(groupedByPrefix) {
|
||||
const byFamily = {};
|
||||
|
||||
Object.keys(groupedByPrefix).forEach(prefix => {
|
||||
const family = getReleaseFamily(prefix);
|
||||
if (!byFamily[family]) {
|
||||
byFamily[family] = [];
|
||||
}
|
||||
byFamily[family].push(prefix);
|
||||
});
|
||||
|
||||
const result = {};
|
||||
|
||||
Object.keys(byFamily).forEach(family => {
|
||||
const members = byFamily[family];
|
||||
const hasHotfixes = members.length > 1 || members.some(member => member !== family);
|
||||
|
||||
if (!hasHotfixes && members[0] === family) {
|
||||
result[family] = groupedByPrefix[family];
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = {};
|
||||
members.forEach(member => {
|
||||
branch[member] = groupedByPrefix[member];
|
||||
});
|
||||
result[family] = branch;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getReleaseFamily(prefix) {
|
||||
return String(prefix || '').replace(/-h\d+$/i, '');
|
||||
}
|
||||
|
||||
function getFilePrefix(fileName) {
|
||||
const baseName = String(fileName || '');
|
||||
const underscoreIndex = baseName.indexOf('_');
|
||||
if (underscoreIndex <= 0) {
|
||||
return baseName.replace(/\.json$/i, '') || 'unknown';
|
||||
}
|
||||
return baseName.slice(0, underscoreIndex);
|
||||
}
|
||||
|
||||
function mergeTreeNodes(left, right) {
|
||||
if (!isObjectNode(left)) {
|
||||
return right;
|
||||
}
|
||||
if (!isObjectNode(right)) {
|
||||
return left;
|
||||
}
|
||||
|
||||
const merged = { ...left };
|
||||
|
||||
Object.keys(right).forEach(key => {
|
||||
const leftValue = merged[key];
|
||||
const rightValue = right[key];
|
||||
|
||||
if (key === 'addressed' || key === 'known') {
|
||||
const leftFiles = Array.isArray(leftValue) ? leftValue : [];
|
||||
const rightFiles = Array.isArray(rightValue) ? rightValue : [];
|
||||
merged[key] = Array.from(new Set([...leftFiles, ...rightFiles]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isObjectNode(leftValue) && isObjectNode(rightValue)) {
|
||||
merged[key] = mergeTreeNodes(leftValue, rightValue);
|
||||
return;
|
||||
}
|
||||
|
||||
merged[key] = rightValue;
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isObjectNode(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
+2
-25
@@ -1,5 +1,5 @@
|
||||
import { CheckboxTree, loadManifest } from '../vendor/checkbox-tree/checkbox-tree.js';
|
||||
import { getSortedKeys } from './sort.js';
|
||||
import { toTreeNodes } from './product-tree-model.js';
|
||||
|
||||
const STORAGE_KEY = 'tree-state-v2';
|
||||
let productTree = null;
|
||||
@@ -14,6 +14,7 @@ export function initializeTree(containerId, onSelectionChange) {
|
||||
productTree = new CheckboxTree(container, {
|
||||
storageKey: STORAGE_KEY,
|
||||
initiallyCollapsed: true,
|
||||
stateEncoding: 'tiered',
|
||||
onSelectionChange: event => {
|
||||
onSelectionChange(event);
|
||||
pushTreeState();
|
||||
@@ -82,30 +83,6 @@ function pushTreeState() {
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeNodes(value, path = []) {
|
||||
return getSortedKeys(value)
|
||||
.filter(key => key !== 'addressed' && key !== 'known')
|
||||
.map(key => {
|
||||
const childValue = value[key];
|
||||
const childPath = [...path, key];
|
||||
const isObject = childValue && typeof childValue === 'object';
|
||||
const hasFiles = isObject && ('addressed' in childValue || 'known' in childValue);
|
||||
const children = isObject ? toTreeNodes(childValue, childPath) : [];
|
||||
|
||||
return {
|
||||
id: JSON.stringify(childPath),
|
||||
label: key,
|
||||
selectable: hasFiles || children.length > 0,
|
||||
metadata: {
|
||||
path: childPath,
|
||||
addressed: hasFiles && Array.isArray(childValue.addressed) ? childValue.addressed : [],
|
||||
known: hasFiles && Array.isArray(childValue.known) ? childValue.known : []
|
||||
},
|
||||
...(children.length > 0 ? { children } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeFileRefs(fileRefs) {
|
||||
const seen = new Set();
|
||||
return fileRefs.filter(({ productKey, fileName }) => {
|
||||
|
||||
Vendored
+14
-2
@@ -43,8 +43,8 @@ tree.setData([
|
||||
]);
|
||||
```
|
||||
|
||||
For applications without a bundler, serve or copy the two files in `src/` and
|
||||
use relative URLs:
|
||||
For applications without a bundler, serve or copy the `src/` directory and use
|
||||
relative URLs. The main module imports its state-codec modules alongside it:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="./checkbox-tree.css">
|
||||
@@ -78,6 +78,9 @@ Options:
|
||||
- `onSelectionChange` receives `{ selectedIds, selectedNodes }`.
|
||||
- `manifest` optionally supplies a parsed manifest and automatically restores
|
||||
state from `window.location.hash` after `setData()`.
|
||||
- `stateEncoding` selects `"adaptive"` (the default), `"dense"`, or `"tiered"`.
|
||||
The website's configured value is the URL-format contract and is not embedded
|
||||
in the fragment.
|
||||
|
||||
## Shareable URL state
|
||||
|
||||
@@ -105,6 +108,15 @@ Use `validateManifestEvolution(previous, next)` in a build check to reject
|
||||
removed slots and tombstone reuse; path changes are reported for deliberate
|
||||
rename/move review.
|
||||
|
||||
With `stateEncoding: "tiered"`, `c1` stores root checked state and each deeper
|
||||
`cN` field stores nodes whose checked state differs from their parent. Expansion
|
||||
does not inherit: every `xN` stores the actual expanded branches at that depth,
|
||||
with collapsed as the baseline. Each tier field uses the adaptive codec. The
|
||||
`n` field records manifest coverage so nodes appended later still default to
|
||||
false. A non-selectable parent supplies a false checked baseline. With
|
||||
`stateEncoding: "dense"`, `c` and `x` contain trimmed
|
||||
least-significant-bit-first bitsets.
|
||||
|
||||
Styling is namespaced under `.checkbox-tree`. Override the custom properties
|
||||
`--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and
|
||||
`--checkbox-tree-hover-color` in the consuming application.
|
||||
|
||||
+40
-303
@@ -1,311 +1,44 @@
|
||||
// Dependency-free checkbox tree widget. State serialization lives in focused sibling modules.
|
||||
import {
|
||||
PATH_DELIMITER,
|
||||
assertStateEncoding
|
||||
} from './state-codec.js';
|
||||
import {
|
||||
restoreStateFromLocation,
|
||||
serializeStateToFragment,
|
||||
validateManifest
|
||||
} from './tree-state.js';
|
||||
|
||||
export {
|
||||
PATH_DELIMITER,
|
||||
SERIALIZATION_VERSION,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
decodeAdaptiveBitset,
|
||||
encodeAdaptiveBitset,
|
||||
getBit,
|
||||
loadManifest,
|
||||
setBit
|
||||
} from './state-codec.js';
|
||||
export {
|
||||
applyTreeState,
|
||||
decodeTreeState,
|
||||
encodeTreeState,
|
||||
restoreStateFromLocation,
|
||||
serializeStateToFragment,
|
||||
validateManifest,
|
||||
validateManifestEvolution
|
||||
} from './tree-state.js';
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
initiallyCollapsed: true,
|
||||
initiallySelected: false,
|
||||
storageKey: null,
|
||||
onSelectionChange: null,
|
||||
manifest: null
|
||||
manifest: null,
|
||||
stateEncoding: 'adaptive'
|
||||
};
|
||||
|
||||
const SERIALIZATION_VERSION = 1;
|
||||
const PATH_DELIMITER = '>';
|
||||
|
||||
export function loadManifest(source) {
|
||||
let records;
|
||||
let schema = 1;
|
||||
|
||||
if (typeof source === 'string') {
|
||||
records = source.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
.map(line => line === '!deleted' ? null : line);
|
||||
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
|
||||
schema = source.schema;
|
||||
records = source.nodes;
|
||||
} else if (source && Array.isArray(source.slots)) {
|
||||
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
|
||||
} else {
|
||||
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
|
||||
}
|
||||
|
||||
return normalizeManifest(schema, records);
|
||||
}
|
||||
|
||||
function normalizeManifest(schema, records) {
|
||||
if (schema !== 1) {
|
||||
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
|
||||
}
|
||||
|
||||
const pathToSlot = new Map();
|
||||
const slots = records.map((record, index) => {
|
||||
if (record === null || record === '!deleted') {
|
||||
return { path: null, deleted: true };
|
||||
}
|
||||
if (typeof record !== 'string' || !record || record.trim() !== record) {
|
||||
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
|
||||
}
|
||||
if (pathToSlot.has(record)) {
|
||||
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
|
||||
}
|
||||
pathToSlot.set(record, index);
|
||||
return { path: record, deleted: false };
|
||||
});
|
||||
|
||||
return { schema: 1, slots, pathToSlot };
|
||||
}
|
||||
|
||||
export function setBit(bytes, bitIndex, value) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
const mask = 1 << (bitIndex % 8);
|
||||
if (value) bytes[byteIndex] |= mask;
|
||||
else bytes[byteIndex] &= ~mask;
|
||||
}
|
||||
|
||||
export function getBit(bytes, bitIndex) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(value, maximumLength = Infinity) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new Error('Invalid Base64URL value.');
|
||||
}
|
||||
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
|
||||
throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
}
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
|
||||
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function trimTrailingZeroBytes(bytes) {
|
||||
let end = bytes.length;
|
||||
while (end && bytes[end - 1] === 0) end--;
|
||||
return bytes.slice(0, end);
|
||||
}
|
||||
|
||||
function encodeUnsignedVarint(value) {
|
||||
const bytes = [];
|
||||
do {
|
||||
let byte = value & 0x7f;
|
||||
value = Math.floor(value / 128);
|
||||
if (value) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeUnsignedVarint(bytes, offset) {
|
||||
let value = 0;
|
||||
let multiplier = 1;
|
||||
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
|
||||
const byte = bytes[index];
|
||||
value += (byte & 0x7f) * multiplier;
|
||||
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
|
||||
multiplier *= 128;
|
||||
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
|
||||
}
|
||||
throw new Error('Invalid adaptive tree-state integer.');
|
||||
}
|
||||
|
||||
function encodeSparseIndexes(indexes) {
|
||||
const bytes = [];
|
||||
let previous = -1;
|
||||
for (const index of indexes) {
|
||||
bytes.push(...encodeUnsignedVarint(index - previous - 1));
|
||||
previous = index;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeSparseIndexes(bytes, offset, limit) {
|
||||
const indexes = new Set();
|
||||
let previous = -1;
|
||||
while (offset < bytes.length) {
|
||||
const decoded = decodeUnsignedVarint(bytes, offset);
|
||||
const index = previous + decoded.value + 1;
|
||||
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
|
||||
indexes.add(index);
|
||||
previous = index;
|
||||
offset = decoded.offset;
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
export function encodeAdaptiveBitset(values, slotCount) {
|
||||
const enabled = [];
|
||||
const disabled = [];
|
||||
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
|
||||
for (const [slot, value] of values) {
|
||||
(value ? enabled : disabled).push(slot);
|
||||
setBit(denseBytes, slot, value);
|
||||
}
|
||||
enabled.sort((left, right) => left - right);
|
||||
disabled.sort((left, right) => left - right);
|
||||
if (enabled.length === 0) return new Uint8Array();
|
||||
|
||||
const candidates = [
|
||||
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
|
||||
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
|
||||
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
|
||||
];
|
||||
return candidates.reduce((shortest, candidate) =>
|
||||
candidate.length < shortest.length ? candidate : shortest
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeAdaptiveBitset(bytes, slotCount) {
|
||||
if (bytes.length === 0) return () => false;
|
||||
const mode = bytes[0];
|
||||
if (mode === 0) {
|
||||
const dense = bytes.slice(1);
|
||||
if (dense.length > Math.ceil(slotCount / 8)) {
|
||||
throw new Error('Dense tree state exceeds the manifest size.');
|
||||
}
|
||||
return slot => getBit(dense, slot);
|
||||
}
|
||||
if (mode === 1) {
|
||||
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
|
||||
return slot => enabled.has(slot);
|
||||
}
|
||||
if (mode === 2) {
|
||||
const coverage = decodeUnsignedVarint(bytes, 1);
|
||||
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
|
||||
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
|
||||
return slot => slot < coverage.value && !disabled.has(slot);
|
||||
}
|
||||
throw new Error('Unknown adaptive tree-state mode.');
|
||||
}
|
||||
|
||||
function asManifest(manifest) {
|
||||
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export function encodeTreeState(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');
|
||||
}
|
||||
|
||||
const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length));
|
||||
const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length));
|
||||
return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null };
|
||||
}
|
||||
|
||||
export function decodeTreeState(fragment, manifest) {
|
||||
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'), warnings: ['Missing or unsupported serialization version.'] };
|
||||
}
|
||||
const maximumLength = Math.max(
|
||||
Math.ceil(normalized.slots.length / 8) + 1,
|
||||
normalized.slots.length * 2 + 10
|
||||
);
|
||||
try {
|
||||
const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength);
|
||||
const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength);
|
||||
decodeAdaptiveBitset(checked, normalized.slots.length);
|
||||
decodeAdaptiveBitset(expanded, normalized.slots.length);
|
||||
return {
|
||||
applied: true,
|
||||
version: SERIALIZATION_VERSION,
|
||||
checked,
|
||||
expanded,
|
||||
warnings: []
|
||||
};
|
||||
} catch (error) {
|
||||
return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTreeState(tree, manifest, state) {
|
||||
if (!state?.applied) return false;
|
||||
const normalized = asManifest(manifest);
|
||||
let isChecked;
|
||||
let isExpanded;
|
||||
try {
|
||||
isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length);
|
||||
isExpanded = decodeAdaptiveBitset(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;
|
||||
}
|
||||
|
||||
export function serializeStateToFragment(tree, manifest) {
|
||||
const state = encodeTreeState(tree, manifest);
|
||||
const params = new URLSearchParams({ v: String(state.version) });
|
||||
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) {
|
||||
const state = decodeTreeState(location.hash ?? '', manifest);
|
||||
if (state.applied) applyTreeState(tree, manifest, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export class CheckboxTree {
|
||||
constructor(container, options = {}) {
|
||||
if (!(container instanceof Element)) {
|
||||
@@ -314,8 +47,10 @@ export class CheckboxTree {
|
||||
|
||||
this.container = container;
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
assertStateEncoding(this.options.stateEncoding);
|
||||
this.nodesById = new Map();
|
||||
this.nodesByPath = new Map();
|
||||
this.itemsById = new Map();
|
||||
this.rootNodes = [];
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
@@ -332,6 +67,7 @@ export class CheckboxTree {
|
||||
|
||||
this.nodesById.clear();
|
||||
this.nodesByPath.clear();
|
||||
this.itemsById.clear();
|
||||
this.rootNodes = nodes;
|
||||
this.indexNodes(nodes);
|
||||
this.render();
|
||||
@@ -371,6 +107,7 @@ export class CheckboxTree {
|
||||
this.container.replaceChildren();
|
||||
this.nodesById.clear();
|
||||
this.nodesByPath.clear();
|
||||
this.itemsById.clear();
|
||||
}
|
||||
|
||||
indexNodes(nodes, parentPath = '') {
|
||||
@@ -408,6 +145,7 @@ export class CheckboxTree {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'checkbox-tree__item';
|
||||
item.dataset.nodeId = node.id;
|
||||
this.itemsById.set(node.id, item);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'checkbox-tree__row';
|
||||
@@ -524,8 +262,7 @@ export class CheckboxTree {
|
||||
}
|
||||
|
||||
itemForNode(id) {
|
||||
return Array.from(this.container.querySelectorAll('.checkbox-tree__item'))
|
||||
.find(item => item.dataset.nodeId === id) ?? null;
|
||||
return this.itemsById.get(id) ?? null;
|
||||
}
|
||||
|
||||
updateAncestorCheckboxes(item) {
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
// Manifest parsing and binary codecs. This module has no DOM dependencies.
|
||||
export const SERIALIZATION_VERSION = 1;
|
||||
export const PATH_DELIMITER = '>';
|
||||
const STATE_ENCODINGS = new Set(['adaptive', 'dense', 'tiered']);
|
||||
|
||||
export function loadManifest(source) {
|
||||
let records;
|
||||
let schema = 1;
|
||||
|
||||
if (typeof source === 'string') {
|
||||
records = source.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
.map(line => line === '!deleted' ? null : line);
|
||||
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
|
||||
schema = source.schema;
|
||||
records = source.nodes;
|
||||
} else if (source && Array.isArray(source.slots)) {
|
||||
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
|
||||
} else {
|
||||
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
|
||||
}
|
||||
|
||||
return normalizeManifest(schema, records);
|
||||
}
|
||||
|
||||
function normalizeManifest(schema, records) {
|
||||
if (schema !== 1) {
|
||||
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
|
||||
}
|
||||
|
||||
const pathToSlot = new Map();
|
||||
const slots = records.map((record, index) => {
|
||||
if (record === null || record === '!deleted') {
|
||||
return { path: null, deleted: true };
|
||||
}
|
||||
if (typeof record !== 'string' || !record || record.trim() !== record) {
|
||||
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
|
||||
}
|
||||
if (pathToSlot.has(record)) {
|
||||
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
|
||||
}
|
||||
pathToSlot.set(record, index);
|
||||
return { path: record, deleted: false };
|
||||
});
|
||||
|
||||
return { schema: 1, slots, pathToSlot };
|
||||
}
|
||||
|
||||
export function setBit(bytes, bitIndex, value) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
const mask = 1 << (bitIndex % 8);
|
||||
if (value) bytes[byteIndex] |= mask;
|
||||
else bytes[byteIndex] &= ~mask;
|
||||
}
|
||||
|
||||
export function getBit(bytes, bitIndex) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(value, maximumLength = Infinity) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new Error('Invalid Base64URL value.');
|
||||
}
|
||||
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
|
||||
throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
}
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
|
||||
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function trimTrailingZeroBytes(bytes) {
|
||||
let end = bytes.length;
|
||||
while (end && bytes[end - 1] === 0) end--;
|
||||
return bytes.slice(0, end);
|
||||
}
|
||||
|
||||
function encodeUnsignedVarint(value) {
|
||||
const bytes = [];
|
||||
do {
|
||||
let byte = value & 0x7f;
|
||||
value = Math.floor(value / 128);
|
||||
if (value) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeUnsignedVarint(bytes, offset) {
|
||||
let value = 0;
|
||||
let multiplier = 1;
|
||||
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
|
||||
const byte = bytes[index];
|
||||
value += (byte & 0x7f) * multiplier;
|
||||
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
|
||||
multiplier *= 128;
|
||||
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
|
||||
}
|
||||
throw new Error('Invalid adaptive tree-state integer.');
|
||||
}
|
||||
|
||||
function encodeSparseIndexes(indexes) {
|
||||
const bytes = [];
|
||||
let previous = -1;
|
||||
for (const index of indexes) {
|
||||
bytes.push(...encodeUnsignedVarint(index - previous - 1));
|
||||
previous = index;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeSparseIndexes(bytes, offset, limit) {
|
||||
const indexes = new Set();
|
||||
let previous = -1;
|
||||
while (offset < bytes.length) {
|
||||
const decoded = decodeUnsignedVarint(bytes, offset);
|
||||
const index = previous + decoded.value + 1;
|
||||
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
|
||||
indexes.add(index);
|
||||
previous = index;
|
||||
offset = decoded.offset;
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
export function encodeAdaptiveBitset(values, slotCount) {
|
||||
const enabled = [];
|
||||
const disabled = [];
|
||||
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
|
||||
for (const [slot, value] of values) {
|
||||
(value ? enabled : disabled).push(slot);
|
||||
setBit(denseBytes, slot, value);
|
||||
}
|
||||
enabled.sort((left, right) => left - right);
|
||||
disabled.sort((left, right) => left - right);
|
||||
if (enabled.length === 0) return new Uint8Array();
|
||||
|
||||
const candidates = [
|
||||
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
|
||||
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
|
||||
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
|
||||
];
|
||||
return candidates.reduce((shortest, candidate) =>
|
||||
candidate.length < shortest.length ? candidate : shortest
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeAdaptiveBitset(bytes, slotCount) {
|
||||
if (bytes.length === 0) return () => false;
|
||||
const mode = bytes[0];
|
||||
if (mode === 0) {
|
||||
const dense = bytes.slice(1);
|
||||
if (dense.length > Math.ceil(slotCount / 8)) {
|
||||
throw new Error('Dense tree state exceeds the manifest size.');
|
||||
}
|
||||
return slot => getBit(dense, slot);
|
||||
}
|
||||
if (mode === 1) {
|
||||
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
|
||||
return slot => enabled.has(slot);
|
||||
}
|
||||
if (mode === 2) {
|
||||
const coverage = decodeUnsignedVarint(bytes, 1);
|
||||
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
|
||||
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
|
||||
return slot => slot < coverage.value && !disabled.has(slot);
|
||||
}
|
||||
throw new Error('Unknown adaptive tree-state mode.');
|
||||
}
|
||||
|
||||
|
||||
export function encodeDenseBitset(values, slotCount) {
|
||||
const bytes = new Uint8Array(Math.ceil(slotCount / 8));
|
||||
for (const [slot, value] of values) setBit(bytes, slot, value);
|
||||
return trimTrailingZeroBytes(bytes);
|
||||
}
|
||||
|
||||
export function decodeDenseBitset(bytes, slotCount) {
|
||||
if (bytes.length > Math.ceil(slotCount / 8)) {
|
||||
throw new Error('Dense tree state exceeds the manifest size.');
|
||||
}
|
||||
return slot => getBit(bytes, slot);
|
||||
}
|
||||
|
||||
export function maximumEncodedLength(slotCount) {
|
||||
return Math.max(Math.ceil(slotCount / 8) + 1, slotCount * 2 + 10);
|
||||
}
|
||||
|
||||
export function assertStateEncoding(encoding) {
|
||||
if (!STATE_ENCODINGS.has(encoding)) {
|
||||
throw new Error(`Unsupported CheckboxTree state encoding: ${encoding}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function asManifestSource(manifest) {
|
||||
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
|
||||
}
|
||||
+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