react-native-tree-multi-select
Version:
Super-fast, customizable tree view component for React Native with drag-and-drop reordering, multi-selection, and search filtering.
256 lines (242 loc) • 8.03 kB
JavaScript
"use strict";
import { getTreeViewStore } from "../store/treeView.store.js";
import { initializeNodeMaps } from "./treeNode.helper.js";
import { recalculateCheckedStates } from "./toggleCheckbox.helper.js";
import { expandNodes } from "./expandCollapse.helper.js";
/**
* Move a node within a tree structure. Returns a new tree (no mutation).
*
* @param data - The current tree data
* @param draggedNodeId - The ID of the node to move
* @param targetNodeId - The ID of the target node
* @param position - Where to place relative to target: "above", "below", or "inside"
* @returns New tree data with the node moved, or the original data if the move is invalid
*/
export function moveTreeNode(data, draggedNodeId, targetNodeId, position) {
if (draggedNodeId === targetNodeId) return data;
// Prevent moving a node into its own descendant (would create a cycle)
if (isDescendant(data, draggedNodeId, targetNodeId)) return data;
// Step 1: Deep clone the tree
const cloned = deepCloneTree(data);
// Step 2: Remove the dragged node
const removedNode = removeNodeById(cloned, draggedNodeId);
if (!removedNode) return data;
// Step 3: Insert at the new position
const inserted = insertNode(cloned, removedNode, targetNodeId, position);
if (!inserted) return data;
return cloned;
}
/**
* Commit the result of a `moveTreeNode` to the store: swap in the new tree,
* rebuild the node maps, recalculate parent checked/indeterminate states, and
* expand whatever is needed to make the moved node visible ("inside" drops
* expand the target; ancestors of the moved node are always expanded).
*
* Shared by the interactive drag commit (useDragDrop.handleDragEnd) and the
* programmatic `TreeViewRef.moveNode` so the two paths cannot drift.
*/
export function applyMoveToStore(storeId, newData, movedNodeId, targetNodeId, position) {
const store = getTreeViewStore(storeId);
store.getState().updateInitialTreeViewData(newData);
initializeNodeMaps(storeId, newData);
recalculateCheckedStates(storeId);
if (position === "inside") {
expandNodes(storeId, [targetNodeId]);
}
expandNodes(storeId, [movedNodeId], true);
}
/**
* Locate a node within a tree, returning its parent id (null at root) and its
* index within that parent's children (or the root array). Returns null if the
* node is not found. Iterative (stack-based) DFS.
*
* Used to build the lightweight `MoveResult` delta (previous/new parent + index)
* without exposing a full tree copy.
*/
export function findNodePosition(data, nodeId) {
const stack = [{
nodes: data,
parentId: null
}];
while (stack.length > 0) {
const {
nodes,
parentId
} = stack.pop();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.id === nodeId) return {
parentId,
index: i
};
if (node.children?.length) {
stack.push({
nodes: node.children,
parentId: node.id
});
}
}
}
return null;
}
/**
* `findNodePosition` for a node that is still in the store's CURRENT tree:
* uses the already-built childToParentMap/nodeMap for O(depth + siblings)
* instead of a full-tree DFS. Only valid while the maps match `data` (i.e.
* before the move mutates the tree).
*/
export function findNodePositionFromMaps(data, nodeMap, childToParentMap, nodeId) {
const parentId = childToParentMap.get(nodeId);
const siblings = parentId !== undefined ? nodeMap.get(parentId)?.children : data;
if (!siblings) return null;
const index = siblings.findIndex(n => n.id === nodeId);
return index === -1 ? null : {
parentId: parentId ?? null,
index
};
}
/**
* Check if `candidateDescendantId` is a descendant of `ancestorId` in the tree.
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
*/
function isDescendant(nodes, ancestorId, candidateDescendantId) {
const stack = [...nodes];
while (stack.length > 0) {
const node = stack.pop();
if (node.id === ancestorId) {
// Found the ancestor - search its subtree for the candidate.
// IDs are unique, so the first match is the only ancestor to check.
return containsNode(node.children ?? [], candidateDescendantId);
}
if (node.children) {
for (const child of node.children) stack.push(child);
}
}
return false;
}
/**
* Check if a node with the given ID exists anywhere in the subtree.
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
*/
function containsNode(nodes, nodeId) {
const stack = [...nodes];
while (stack.length > 0) {
const node = stack.pop();
if (node.id === nodeId) return true;
if (node.children) {
for (const child of node.children) stack.push(child);
}
}
return false;
}
/**
* Deep clone a tree structure so mutations don't affect the original.
* Iterative (stack-based) clone to avoid call-stack limits on deep trees.
* Preserves the original shape: every node carries a `children` key
* (`undefined` for leaves, a cloned array - possibly empty - otherwise).
*/
function deepCloneTree(nodes) {
const root = nodes.map(node => ({
...node,
children: undefined
}));
const stack = [{
src: nodes,
dst: root
}];
while (stack.length > 0) {
const {
src,
dst
} = stack.pop();
for (let i = 0; i < src.length; i++) {
const children = src[i].children;
if (children) {
const clonedChildren = children.map(child => ({
...child,
children: undefined
}));
dst[i].children = clonedChildren;
stack.push({
src: children,
dst: clonedChildren
});
}
}
}
return root;
}
/**
* Remove a node by ID from the tree. Mutates the cloned tree in-place.
* Returns the removed node, or null if not found.
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
*/
function removeNodeById(nodes, nodeId) {
// Each frame carries the array being scanned plus the node that owns it
// (null at the root) so an emptied children array can be detached.
const stack = [{
nodes,
parent: null
}];
while (stack.length > 0) {
const {
nodes: level,
parent
} = stack.pop();
for (let i = 0; i < level.length; i++) {
const node = level[i];
if (node.id === nodeId) {
const [removed] = level.splice(i, 1);
// Clean up an emptied children array on the owning parent
// (matches the original shape: leaves carry children: undefined).
if (parent && level.length === 0) {
parent.children = undefined;
}
/* istanbul ignore next -- splice at a found index always yields
the element; noUncheckedIndexedAccess guard */
return removed ?? null;
}
if (node.children?.length) {
stack.push({
nodes: node.children,
parent: node
});
}
}
}
return null;
}
/**
* Insert a node relative to a target node. Mutates the cloned tree in-place.
* Returns true if insertion was successful.
* Iterative (stack-based) DFS to avoid call-stack limits on deep trees.
*/
function insertNode(nodes, nodeToInsert, targetId, position) {
const stack = [nodes];
while (stack.length > 0) {
const level = stack.pop();
for (let i = 0; i < level.length; i++) {
const node = level[i];
if (node.id === targetId) {
if (position === "above") {
level.splice(i, 0, nodeToInsert);
} else if (position === "below") {
level.splice(i + 1, 0, nodeToInsert);
} else {
// "inside" - add as first child
if (node.children) {
node.children.unshift(nodeToInsert);
} else {
node.children = [nodeToInsert];
}
}
return true;
}
if (node.children?.length) {
stack.push(node.children);
}
}
}
return false;
}
//# sourceMappingURL=moveTreeNode.helper.js.map