export type NavTreeDropIntent = "before" | "after" | "inside"; export type NavTreeDropTarget = { parentId: string | null; index: number; intent: NavTreeDropIntent; targetNodeId?: string; }; export type NavTreeDragState = { draggedNodeId: string; dropTarget: NavTreeDropTarget | null; }; export type NavTreeLocation = { parentId: string | null; index: number; node: TNode; }; export type NavTreeAdapter = { getNodeId: (node: TNode) => string; isBranchNode: (node: TNode) => boolean; getChildren: (node: TNode) => readonly TNode[]; withChildren: (node: TNode, children: readonly TNode[]) => TNode; }; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export const isUuidString = (value: string | null | undefined): boolean => { if (typeof value !== "string") { return false; } return UUID_PATTERN.test(value.trim()); }; export const collectBranchNodeIds = ( nodes: readonly TNode[], adapter: NavTreeAdapter, ): string[] => { const ids: string[] = []; for (const node of nodes) { if (!adapter.isBranchNode(node)) { continue; } ids.push(adapter.getNodeId(node)); ids.push(...collectBranchNodeIds(adapter.getChildren(node), adapter)); } return ids; }; export const findTreeNodeLocation = ( nodes: readonly TNode[], nodeId: string, adapter: NavTreeAdapter, parentId: string | null = null, ): NavTreeLocation | null => { for (let index = 0; index < nodes.length; index += 1) { const node = nodes[index]; if (adapter.getNodeId(node) === nodeId) { return { parentId, index, node }; } if (!adapter.isBranchNode(node)) { continue; } const nestedLocation = findTreeNodeLocation(adapter.getChildren(node), nodeId, adapter, adapter.getNodeId(node)); if (nestedLocation) { return nestedLocation; } } return null; }; export const findTreeNodeDepth = ( nodes: readonly TNode[], nodeId: string, adapter: NavTreeAdapter, depth = 0, ): number | null => { for (const node of nodes) { if (adapter.getNodeId(node) === nodeId) { return depth; } if (!adapter.isBranchNode(node)) { continue; } const nestedDepth = findTreeNodeDepth(adapter.getChildren(node), nodeId, adapter, depth + 1); if (nestedDepth !== null) { return nestedDepth; } } return null; }; export const treeContainsNode = ( nodes: readonly TNode[], nodeId: string, adapter: NavTreeAdapter, ): boolean => { for (const node of nodes) { if (adapter.getNodeId(node) === nodeId) { return true; } if (adapter.isBranchNode(node) && treeContainsNode(adapter.getChildren(node), nodeId, adapter)) { return true; } } return false; }; export const removeTreeNode = ( nodes: readonly TNode[], nodeId: string, adapter: NavTreeAdapter, ): { nodes: TNode[]; removed: TNode | null } => { const nextNodes: TNode[] = []; let removed: TNode | null = null; for (const node of nodes) { if (adapter.getNodeId(node) === nodeId) { removed = node; continue; } if (adapter.isBranchNode(node)) { const result = removeTreeNode(adapter.getChildren(node), nodeId, adapter); if (result.removed) { removed = result.removed; nextNodes.push(adapter.withChildren(node, result.nodes)); continue; } } nextNodes.push(node); } return { nodes: nextNodes, removed }; }; export const insertTreeNode = ( nodes: readonly TNode[], parentId: string | null, index: number, nodeToInsert: TNode, adapter: NavTreeAdapter, ): TNode[] => { if (parentId === null) { const nextNodes = [...nodes]; nextNodes.splice(Math.max(0, Math.min(index, nextNodes.length)), 0, nodeToInsert); return nextNodes; } return nodes.map((node) => { if (!adapter.isBranchNode(node)) { return node; } if (adapter.getNodeId(node) === parentId) { const nextChildren = [...adapter.getChildren(node)]; nextChildren.splice(Math.max(0, Math.min(index, nextChildren.length)), 0, nodeToInsert); return adapter.withChildren(node, nextChildren); } return adapter.withChildren(node, insertTreeNode(adapter.getChildren(node), parentId, index, nodeToInsert, adapter)); }); }; export const moveTreeNode = ( nodes: readonly TNode[], draggedNodeId: string, dropTarget: NavTreeDropTarget, adapter: NavTreeAdapter, ): TNode[] => { const location = findTreeNodeLocation(nodes, draggedNodeId, adapter); if (!location) { return [...nodes]; } if ( adapter.isBranchNode(location.node) && dropTarget.parentId !== null && (treeContainsNode(adapter.getChildren(location.node), dropTarget.parentId, adapter) || dropTarget.parentId === adapter.getNodeId(location.node)) ) { return [...nodes]; } let normalizedIndex = dropTarget.index; if (dropTarget.parentId === location.parentId && dropTarget.index > location.index) { normalizedIndex -= 1; } if (dropTarget.parentId === location.parentId && normalizedIndex === location.index) { return [...nodes]; } const removalResult = removeTreeNode(nodes, draggedNodeId, adapter); if (!removalResult.removed) { return [...nodes]; } return insertTreeNode(removalResult.nodes, dropTarget.parentId, normalizedIndex, removalResult.removed, adapter); }; export const getPointerRelativeY = (event: PointerEvent): number | null => { const currentTarget = event.currentTarget; if (!(currentTarget instanceof HTMLElement)) { return null; } const bounds = currentTarget.getBoundingClientRect(); return bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height; }; export const resolveTreeDropTarget = (params: { parentId: string | null; index: number; node: TNode; relativeY: number; adapter: NavTreeAdapter; beforeThreshold?: number; beforeThresholdFirstSibling?: number; afterThreshold?: number; }): NavTreeDropTarget => { const { parentId, index, node, relativeY, adapter, beforeThreshold = 0.28, beforeThresholdFirstSibling = 0.42, afterThreshold = 0.72, } = params; const targetNodeId = adapter.getNodeId(node); if (adapter.isBranchNode(node)) { const nextBeforeThreshold = index === 0 ? beforeThresholdFirstSibling : beforeThreshold; if (relativeY < nextBeforeThreshold) { return { parentId, index, intent: "before", targetNodeId }; } if (relativeY > afterThreshold) { return { parentId, index: index + 1, intent: "after", targetNodeId }; } return { parentId: targetNodeId, index: adapter.getChildren(node).length, intent: "inside", targetNodeId, }; } return { parentId, index: relativeY < 0.5 ? index : index + 1, intent: relativeY < 0.5 ? "before" : "after", targetNodeId, }; };