Skip to content

Commit 65d1fbf

Browse files
feat(useTreeCollapse): add hook with collapse state management
1 parent b47af14 commit 65d1fbf

4 files changed

Lines changed: 1180 additions & 0 deletions

File tree

packages/hooks/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './useConfig/index.js';
22
export * from './useMergedState/index.js';
3+
export * from './useTreeCollapse/index.js';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { computeNodeStates, useTreeCollapse } from './useTreeCollapse.js';
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { getSubtreeSize } from '@tabnews/helpers';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
4+
/**
5+
* @typedef {Object} TreeNode
6+
* @property {string} id - Unique identifier for the node.
7+
* @property {TreeNode[]} [children] - Child nodes of the current node.
8+
* @property {number} [children_deep_count] - Total number of nested children.
9+
* @property {number} [collapsedSize] - Render size when the node is collapsed.
10+
* @property {number} [expandedSize] - Render size when the node is expanded.
11+
*/
12+
13+
/**
14+
* @typedef {Object} useTreeCollapseReturn
15+
* @property {TreeNode[]} nodeStates - The current computed state of the first-level children of the tree.
16+
* @property {(id: string) => void} handleExpand - Expands a node and adjusts visibility of related nodes based on budget constraints.
17+
* @property {(id: string) => void} handleCollapse - Collapses a node's immediate children by its ID.
18+
*/
19+
20+
/**
21+
* @typedef {Object} useTreeCollapseParams
22+
* @property {TreeNode[]} [nodes=[]] - The initial list of tree nodes.
23+
* @property {number} [minimalSubTree=3] - The minimum size of a subtree to be expanded.
24+
* @property {number} [totalBudget=20] - The total node rendering budget available.
25+
* @property {number} [additionalBudget=10] - The additional budget allocated when expanding a node.
26+
* @property {string|null} [defaultExpandedId=null] - The ID of the node to expand by default.
27+
*/
28+
29+
/**
30+
* Hook to manage collapse/expand logic for the first children level of a tree structure,
31+
* using rendering budget constraints. It returns all tree nodes annotated with their current
32+
* expansion state, along with handlers to toggle visibility.
33+
*
34+
* @param {useTreeCollapseParams} [params] - Parameters to configure the tree collapse behavior.
35+
* @returns {useTreeCollapseReturn} - All nodes with computed expansion metadata and controls to modify their state.
36+
*/
37+
export function useTreeCollapse({
38+
nodes = [],
39+
minimalSubTree = 3,
40+
totalBudget = 20,
41+
additionalBudget = 10,
42+
defaultExpandedId = null,
43+
} = {}) {
44+
const lastParamsRef = useRef({ totalBudget, minimalSubTree, defaultExpandedId });
45+
46+
const [nodeStates, setNodeStates] = useState(() =>
47+
computeNodeStates({
48+
minimalSubTree,
49+
nodes,
50+
totalBudget,
51+
defaultExpandedId,
52+
}),
53+
);
54+
55+
useEffect(() => {
56+
const shouldUsePrevious =
57+
lastParamsRef.current.totalBudget === totalBudget &&
58+
lastParamsRef.current.minimalSubTree === minimalSubTree &&
59+
lastParamsRef.current.defaultExpandedId === defaultExpandedId;
60+
61+
if (shouldUsePrevious) {
62+
setNodeStates((previousState) =>
63+
computeNodeStates({
64+
minimalSubTree,
65+
nodes,
66+
previousState,
67+
totalBudget,
68+
defaultExpandedId,
69+
}),
70+
);
71+
} else {
72+
lastParamsRef.current = { totalBudget, minimalSubTree, defaultExpandedId };
73+
setNodeStates(
74+
computeNodeStates({
75+
minimalSubTree,
76+
nodes,
77+
totalBudget,
78+
defaultExpandedId,
79+
}),
80+
);
81+
}
82+
}, [defaultExpandedId, nodes, minimalSubTree, totalBudget]);
83+
84+
const handleExpand = useCallback(
85+
(targetId) => {
86+
setNodeStates((previousState) =>
87+
expandChildren({
88+
additionalBudget,
89+
minimalSubTree,
90+
previousState,
91+
targetId,
92+
}),
93+
);
94+
},
95+
[additionalBudget, minimalSubTree],
96+
);
97+
98+
const handleCollapse = useCallback((targetId) => {
99+
setNodeStates((previousState) =>
100+
collapseChildren({
101+
previousState,
102+
targetId,
103+
}),
104+
);
105+
}, []);
106+
107+
return {
108+
handleCollapse,
109+
handleExpand,
110+
nodeStates,
111+
};
112+
}
113+
114+
/**
115+
* Computes the initial state of tree nodes based on budget constraints and previous state.
116+
*
117+
* @param {Object} params
118+
* @param {number} [params.minimalSubTree=1] - Minimum size of a subtree to be expanded
119+
* @param {TreeNode[]} params.nodes - Array of tree nodes to process
120+
* @param {TreeNode[]} [params.previousState=[]] - Previous state to maintain expanded sizes
121+
* @param {number} params.totalBudget - Total budget available for node expansion
122+
* @param {string|null} [params.defaultExpandedId=null] - ID of the node to expand by default
123+
* @returns {TreeNode[]} Array of nodes with computed expansion states
124+
*/
125+
export function computeNodeStates({
126+
minimalSubTree = 1,
127+
nodes,
128+
previousState = [],
129+
totalBudget,
130+
defaultExpandedId = null,
131+
}) {
132+
if (!nodes || !Array.isArray(nodes) || !nodes.length) return nodes;
133+
134+
let remainingBudget = totalBudget;
135+
136+
const previousExpandedSizes = new Map(
137+
previousState
138+
.filter((node) => typeof node?.id === 'string' && typeof node.expandedSize === 'number')
139+
.map((node) => [node.id, node.expandedSize]),
140+
);
141+
142+
const initialPass = nodes.map((node) => {
143+
if (typeof node?.id !== 'string') return node;
144+
145+
const cachedExpandedSize = previousExpandedSizes.get(node.id);
146+
147+
if (cachedExpandedSize >= 0) {
148+
remainingBudget -= cachedExpandedSize;
149+
return { ...node, expandedSize: cachedExpandedSize };
150+
}
151+
152+
if (remainingBudget > 0 || node.id === defaultExpandedId) {
153+
const maxFullDepth = getSubtreeSize(node);
154+
const allocated = Math.max(1, Math.min(minimalSubTree, remainingBudget, maxFullDepth));
155+
remainingBudget -= allocated;
156+
return { ...node, expandedSize: allocated };
157+
}
158+
159+
return { ...node, expandedSize: 0 };
160+
});
161+
162+
const grouped = groupCollapsed(initialPass);
163+
return distributeRemainingBudget(grouped, remainingBudget);
164+
}
165+
166+
/**
167+
* Groups consecutive collapsed nodes and calculates their combined collapsed size.
168+
*
169+
* @param {TreeNode[]} nodes - Array of nodes to process
170+
* @returns {TreeNode[]} Array with collapsed nodes grouped together
171+
*/
172+
function groupCollapsed(nodes) {
173+
const result = [];
174+
let i = 0;
175+
176+
while (i < nodes?.length) {
177+
const node = nodes[i];
178+
179+
if (node?.expandedSize === 0) {
180+
let collapsedSize = getSubtreeSize(node);
181+
let j = i + 1;
182+
183+
// Find consecutive collapsed nodes
184+
while (j < nodes.length && nodes[j]?.expandedSize === 0) {
185+
collapsedSize += getSubtreeSize(nodes[j]);
186+
j++;
187+
}
188+
189+
// Add the first node with combined collapsed size
190+
result.push({ ...node, collapsedSize });
191+
192+
// Add remaining nodes in the group without collapsedSize
193+
for (let k = i + 1; k < j; k++) {
194+
result.push({ ...nodes[k] });
195+
}
196+
197+
i = j;
198+
} else {
199+
result.push(node);
200+
i++;
201+
}
202+
}
203+
204+
return result;
205+
}
206+
207+
/**
208+
* Distributes any remaining budget across nodes that can still be expanded.
209+
*
210+
* @param {TreeNode[]} nodes - Array of nodes to distribute budget to
211+
* @param {number} remainingBudget - Amount of budget still available
212+
* @returns {TreeNode[]} Array of nodes with updated expanded sizes
213+
*/
214+
function distributeRemainingBudget(nodes, remainingBudget) {
215+
if (remainingBudget <= 0) return nodes;
216+
217+
return nodes.map((node) => {
218+
if (remainingBudget <= 0 || !node?.expandedSize) return node;
219+
220+
const maxDepth = node.collapsedSize || getSubtreeSize(node);
221+
222+
if (node.expandedSize >= maxDepth) return node;
223+
224+
const extra = Math.min(remainingBudget, maxDepth - node.expandedSize);
225+
remainingBudget -= extra;
226+
227+
return {
228+
...node,
229+
expandedSize: node.expandedSize + extra,
230+
};
231+
});
232+
}
233+
234+
/**
235+
* Expands collapsed children of a target node by allocating additional budget.
236+
* This function finds consecutive collapsed nodes starting from the target and expands them.
237+
*
238+
* @param {Object} params - Parameters for expansion
239+
* @param {number} params.additionalBudget - Additional budget to allocate for expansion
240+
* @param {number} params.minimalSubTree - Minimum size for subtree expansion
241+
* @param {TreeNode[]} params.previousState - Current state of all nodes
242+
* @param {string} params.targetId - ID of the target node to expand
243+
* @returns {TreeNode[]} Updated array with expanded nodes
244+
*/
245+
function expandChildren({ additionalBudget, minimalSubTree, previousState, targetId }) {
246+
const startIndex = previousState.findIndex((node) => node?.id === targetId);
247+
if (startIndex < 0) return previousState;
248+
249+
const nodes = [];
250+
let endIndex = startIndex;
251+
252+
// Collect consecutive collapsed nodes
253+
while (previousState[endIndex]?.expandedSize === 0) {
254+
const { collapsedSize, ...nodeWithoutCollapsedSize } = previousState[endIndex];
255+
nodes.push(nodeWithoutCollapsedSize);
256+
endIndex++;
257+
}
258+
259+
const expanded = computeNodeStates({
260+
minimalSubTree,
261+
nodes,
262+
totalBudget: additionalBudget,
263+
});
264+
265+
return [...previousState.slice(0, startIndex), ...expanded, ...previousState.slice(endIndex)];
266+
}
267+
268+
/**
269+
* Collapses a node and updates the collapsed size information for adjacent collapsed nodes.
270+
*
271+
* @param {Object} params - Parameters for collapsing
272+
* @param {TreeNode[]} params.previousState - Current state of all nodes
273+
* @param {string} params.targetId - ID of the target node to collapse
274+
* @returns {TreeNode[]} Updated array with collapsed node
275+
*/
276+
function collapseChildren({ previousState, targetId }) {
277+
const targetNodeIndex = previousState.findIndex((node) => node?.id === targetId);
278+
if (targetNodeIndex < 0 || previousState[targetNodeIndex].expandedSize === 0) return previousState;
279+
280+
const result = [...previousState];
281+
const originalTargetNode = result[targetNodeIndex];
282+
const targetNode = {
283+
...originalTargetNode,
284+
// Collapse the target node
285+
expandedSize: 0,
286+
collapsedSize: getSubtreeSize(originalTargetNode),
287+
};
288+
result[targetNodeIndex] = targetNode;
289+
290+
// Update collapsed size if next node is also collapsed
291+
if (result[targetNodeIndex + 1]?.collapsedSize) {
292+
targetNode.collapsedSize += result[targetNodeIndex + 1].collapsedSize;
293+
const nextNode = { ...result[targetNodeIndex + 1] };
294+
delete nextNode.collapsedSize;
295+
result[targetNodeIndex + 1] = nextNode;
296+
}
297+
298+
// Find the first collapsed node in the sequence (going backwards)
299+
let firstCollapsedIndex = targetNodeIndex;
300+
while (result[firstCollapsedIndex - 1]?.expandedSize === 0) {
301+
firstCollapsedIndex--;
302+
}
303+
304+
// If target is not the first collapsed node, move collapsedSize to the first one
305+
if (firstCollapsedIndex < targetNodeIndex) {
306+
const totalCollapsedSize = result[firstCollapsedIndex].collapsedSize + targetNode.collapsedSize;
307+
const firstNode = { ...result[firstCollapsedIndex], collapsedSize: totalCollapsedSize };
308+
result[firstCollapsedIndex] = firstNode;
309+
delete targetNode.collapsedSize;
310+
}
311+
312+
return result;
313+
}

0 commit comments

Comments
 (0)