import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { View, StyleSheet, TouchableOpacity, Platform, type NativeSyntheticEvent, type NativeScrollEvent, type GestureResponderEvent, type LayoutChangeEvent, } from "react-native"; import { FlashList } from "@shopify/flash-list"; import type { __FlattenedTreeNode__, DropIndicatorStyleProps, NodeListProps, NodeProps, } from "../types/treeView.types"; import { useTreeViewStore } from "../store/treeView.store"; import { getFilteredTreeData, getFlattenedTreeData, getCheckboxValue, getInnerMostChildrenIdsInTree, handleToggleExpand, toggleCheckboxes } from "../helpers"; import { CheckboxView } from "./CheckboxView"; import { CustomExpandCollapseIcon } from "./CustomExpandCollapseIcon"; import { DragOverlay } from "./DragOverlay"; import type { DropPosition } from "../types/dragDrop.types"; import { defaultIndentationMultiplier, defaultItemHeight, listHeaderFooterPadding } from "../constants/treeView.constants"; import { useShallow } from "zustand/react/shallow"; import { typedMemo } from "../utils/typedMemo"; import { useScrollToNode } from "../hooks/useScrollToNode"; import { useDragDrop } from "../hooks/useDragDrop"; const NodeList = typedMemo(_NodeList); export default NodeList; function _NodeList(props: NodeListProps) { const { storeId, scrollToNodeHandlerRef, initialScrollNodeID, treeFlashListProps, checkBoxViewStyleProps, indentationMultiplier, CheckboxComponent, ExpandCollapseIconComponent, ExpandCollapseTouchableComponent, CustomNodeRowComponent, dragAndDrop, } = props; const { enabled: _dragEnabled, onDragStart, onDragEnd, onDragCancel, longPressDuration = 400, autoScrollThreshold = 60, autoScrollSpeed = 1.0, dragOverlayOffset = -2, overlayYCorrection, autoExpandDelay = 800, autoExpand = true, magneticSnap = true, customizations: dragDropCustomizations, canDrop: canDropCallback, maxDepth, canNodeHaveChildren, canDrag, autoScrollToDroppedNode, } = dragAndDrop ?? {}; // When the dragAndDrop prop is provided, drag is enabled by default on native // (iOS/Android). On web it defaults OFF because the PanResponder-based drag is // still a work in progress there - consumers can opt in with `enabled: true`. const dragEnabled = dragAndDrop ? (_dragEnabled ?? Platform.OS !== "web") : false; const { expanded, initialTreeViewData, updateInnerMostChildrenIds, searchKeys, searchText } = useTreeViewStore(storeId)(useShallow( state => ({ expanded: state.expanded, initialTreeViewData: state.initialTreeViewData, updateInnerMostChildrenIds: state.updateInnerMostChildrenIds, searchKeys: state.searchKeys, searchText: state.searchText, }) )); const flashListRef = useRef> | null>(null); const containerRef = useRef(null); const measuredItemHeightRef = useRef(0); const contentHeightRef = useRef(0); // Measured row heights keyed by stable node id (NOT flattened index, which is // reused by different nodes across expand/collapse/filter/reorder). Used for // accurate drop targeting with variable-height rows when the whole list is // rendered. Keying by id means a stale entry can never satisfy the // "all current rows measured" gate in useDragDrop. const itemHeightsRef = useRef>(new Map()); const handleItemLayout = useCallback((id: ID, height: number) => { if (height > 0) { itemHeightsRef.current.set(id, height); // First measured height seeds the uniform fallback used while // virtualization keeps some rows unmeasured. if (measuredItemHeightRef.current === 0) { measuredItemHeightRef.current = height; } } }, []); // Measured heights of removed nodes would otherwise accumulate forever on // dynamic trees; clear on structural change and let rows re-measure on layout. useEffect(() => { itemHeightsRef.current.clear(); }, [initialTreeViewData]); const [initialScrollIndex, setInitialScrollIndex] = useState(-1); // First we filter the tree as per the search term and keys const filteredTree = useMemo(() => getFilteredTreeData( initialTreeViewData, searchText.trim().toLowerCase(), searchKeys ), [initialTreeViewData, searchText, searchKeys]); // Then we flatten the tree to make it "render-compatible" in a "flat" list const flattenedFilteredNodes = useMemo(() => getFlattenedTreeData( filteredTree, expanded, ), [filteredTree, expanded]); useScrollToNode({ storeId, scrollToNodeHandlerRef, flashListRef, flattenedFilteredNodes, setInitialScrollIndex, initialScrollNodeID, }); // And update the innermost children id -> required to un/select filtered tree useEffect(() => { const updatedInnerMostChildrenIds = getInnerMostChildrenIdsInTree( filteredTree ); updateInnerMostChildrenIds(updatedInnerMostChildrenIds); }, [filteredTree, updateInnerMostChildrenIds]); const effectiveIndentationMultiplier = indentationMultiplier ?? defaultIndentationMultiplier; // --- Drag and drop --- const { panResponder, overlayY, overlayX, isDragging, draggedNode, handleNodeTouchStart, handleNodeTouchEnd, handleScroll: dragHandleScroll, containerHeightRef, } = useDragDrop({ storeId, flattenedNodes: flattenedFilteredNodes, flashListRef, containerRef, dragEnabled, onDragStart, onDragEnd, onDragCancel, longPressDuration, autoScrollThreshold, autoScrollSpeed, measuredItemHeightRef, contentHeightRef, itemHeightsRef, dragOverlayOffset, overlayYCorrection, autoExpandDelay, autoExpand, magneticSnap, indentationMultiplier: effectiveIndentationMultiplier, canDrop: canDropCallback, maxDepth, canNodeHaveChildren, canDrag, scrollToNodeHandlerRef, autoScrollToDroppedNode, }); // The hook owns scroll-offset bookkeeping (single-writer during drag, // long-press cancellation); this wrapper just forwards to the user's onScroll. const handleScroll = useCallback(( event: NativeSyntheticEvent ) => { dragHandleScroll(event); treeFlashListProps?.onScroll?.(event as any); }, [dragHandleScroll, treeFlashListProps]); // Track total content height so auto-scroll during drag can clamp to the // scrollable range. const handleContentSizeChange = useCallback((width: number, height: number) => { contentHeightRef.current = height; treeFlashListProps?.onContentSizeChange?.(width, height); }, [contentHeightRef, treeFlashListProps]); // Keep the container height fresh on resize (orientation change, keyboard, split // view) so auto-scroll edge detection and clamping don't go stale mid-session. const handleContainerLayout = useCallback((e: LayoutChangeEvent) => { containerHeightRef.current = e.nativeEvent.layout.height; }, [containerHeightRef]); const nodeRenderer = useCallback(( { item, index }: { item: __FlattenedTreeNode__; index: number; } ) => { return ( storeId={storeId} node={item} level={item.level || 0} checkBoxViewStyleProps={checkBoxViewStyleProps} indentationMultiplier={indentationMultiplier} CheckboxComponent={CheckboxComponent} ExpandCollapseIconComponent={ExpandCollapseIconComponent} ExpandCollapseTouchableComponent={ExpandCollapseTouchableComponent} CustomNodeRowComponent={CustomNodeRowComponent} // Index only matters for drag touch bookkeeping; keep it stable // when drag is off so memoized nodes skip index-shift re-renders nodeIndex={dragEnabled ? index : undefined} dragEnabled={dragEnabled} isDragging={isDragging} onNodeTouchStart={dragEnabled ? handleNodeTouchStart : undefined} onNodeTouchEnd={dragEnabled ? handleNodeTouchEnd : undefined} onItemLayout={dragEnabled ? handleItemLayout : undefined} dragDropCustomizations={dragDropCustomizations} /> ); }, [ storeId, CheckboxComponent, ExpandCollapseIconComponent, ExpandCollapseTouchableComponent, CustomNodeRowComponent, checkBoxViewStyleProps, indentationMultiplier, dragEnabled, isDragging, handleNodeTouchStart, handleNodeTouchEnd, dragDropCustomizations, handleItemLayout, ]); // Extract FlashList props but exclude onScroll / onContentSizeChange (we provide // our own combined handlers that still forward to the user's callbacks) // eslint-disable-next-line @typescript-eslint/no-unused-vars const { onScroll: _userOnScroll, onContentSizeChange: _userOnContentSizeChange, ...restFlashListProps } = treeFlashListProps ?? {}; const flashListElement = ( } ListFooterComponent={} {...restFlashListProps} onScroll={handleScroll} onContentSizeChange={handleContentSizeChange} scrollEnabled={isDragging ? false : (restFlashListProps?.scrollEnabled ?? true)} data={flattenedFilteredNodes} renderItem={nodeRenderer} /> ); return ( <> {dragEnabled ? ( {flashListElement} {isDragging && draggedNode && ( storeId={storeId} overlayY={overlayY} overlayX={overlayX} node={draggedNode} /* Constant for the whole drag: the level shift toward the drop target is expressed via the overlayX translate, not a re-render (which caused visible indent flicker). */ level={/* istanbul ignore next -- level is always set by flattenTree */ draggedNode.level ?? 0} indentationMultiplier={effectiveIndentationMultiplier} CheckboxComponent={CheckboxComponent} ExpandCollapseIconComponent={ExpandCollapseIconComponent} CustomNodeRowComponent={CustomNodeRowComponent} checkBoxViewStyleProps={checkBoxViewStyleProps} dragDropCustomizations={dragDropCustomizations} /> )} ) : ( flashListElement )} ); }; function HeaderFooterView() { return ( ); } const Node = typedMemo(_Node); function _Node(props: NodeProps) { const { storeId, node, level, checkBoxViewStyleProps, indentationMultiplier = defaultIndentationMultiplier, ExpandCollapseIconComponent = CustomExpandCollapseIcon, CheckboxComponent = CheckboxView, ExpandCollapseTouchableComponent = TouchableOpacity, CustomNodeRowComponent, nodeIndex = 0, dragEnabled, isDragging: isDraggingGlobal, onNodeTouchStart, onNodeTouchEnd, onItemLayout, dragDropCustomizations, } = props; const { isExpanded, value, isBeingDragged, isDragInvalid, isDropTarget, nodeDropPosition, nodeDropLevel, } = useTreeViewStore(storeId)(useShallow( state => ({ isExpanded: state.expanded.has(node.id), value: getCheckboxValue( state.checked.has(node.id), state.indeterminate.has(node.id) ), isBeingDragged: state.draggedNodeId === node.id, isDragInvalid: state.invalidDragTargetIds.has(node.id), isDropTarget: state.dropTargetNodeId === node.id, nodeDropPosition: state.dropTargetNodeId === node.id ? state.dropPosition : null, nodeDropLevel: state.dropTargetNodeId === node.id ? state.dropLevel : null, }) )); // Track when this node was dragged so we can swallow the onPress/onCheck // that fires when the user lifts their finger after a long-press-initiated drag. // The flag is set during render (synchronous) and cleared on the next touch start. // It is also cleared via effect when dragging ends, to prevent stale `true` // values surviving FlashList recycling (where refs persist across items). const wasDraggedRef = useRef(false); if (isDraggingGlobal && isBeingDragged) { wasDraggedRef.current = true; } useEffect(() => { if (!isDraggingGlobal) { wasDraggedRef.current = false; } }, [isDraggingGlobal]); const _onToggleExpand = useCallback(() => { if (wasDraggedRef.current) return; handleToggleExpand(storeId, node.id); }, [storeId, node.id]); const _onCheck = useCallback(() => { if (wasDraggedRef.current) return; toggleCheckboxes(storeId, [node.id]); }, [storeId, node.id]); const handleTouchStart = useCallback((e: GestureResponderEvent) => { wasDraggedRef.current = false; /* istanbul ignore next -- touch handlers are only attached when dragEnabled, and NodeList always passes both together */ if (!onNodeTouchStart) return; const { pageY, locationY } = e.nativeEvent; onNodeTouchStart(node.id, pageY, locationY, nodeIndex); }, [node.id, nodeIndex, onNodeTouchStart]); const handleTouchEnd = useCallback(() => { onNodeTouchEnd?.(); }, [onNodeTouchEnd]); // Determine opacity for drag state (separate values for dragged node vs invalid targets). // When CustomNodeRowComponent is used, hand off all visual control // (including drag opacity) to the custom component - it receives // isDraggedNode / isInvalidDropTarget / isDragging props. const draggedOpacity = dragDropCustomizations?.draggedNodeOpacity ?? 0.3; const invalidOpacity = dragDropCustomizations?.invalidTargetOpacity ?? 0.3; /* istanbul ignore next -- invalidOpacity side: invalid targets are the dragged node's own descendants, which drag start force-collapses out of the rendered list, so a built-in row never renders as an invalid target */ const nodeOpacity = CustomNodeRowComponent ? 1.0 : isDraggingGlobal ? (isBeingDragged ? draggedOpacity : isDragInvalid ? invalidOpacity : 1.0) : 1.0; const handleLayout = useCallback((e: LayoutChangeEvent) => { onItemLayout?.(node.id, e.nativeEvent.layout.height); }, [onItemLayout, node.id]); const touchHandlers = dragEnabled ? { onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd, onTouchCancel: handleTouchEnd, } : undefined; const CustomDropIndicator = dragDropCustomizations?.CustomDropIndicatorComponent; const indicatorLevel = nodeDropLevel ?? level; const dropIndicator = isDropTarget && nodeDropPosition ? ( CustomDropIndicator ? : ) : null; if (!CustomNodeRowComponent) { return ( {dropIndicator} {node.children?.length ? ( ) : null} ); } else { return ( {dropIndicator} ); } }; function NodeDropIndicator({ position, level, indentationMultiplier, styleProps }: { position: DropPosition; level: number; indentationMultiplier: number; styleProps?: DropIndicatorStyleProps; }) { const lineColor = styleProps?.lineColor ?? "#0078FF"; const lineThickness = styleProps?.lineThickness ?? 3; const circleSize = styleProps?.circleSize ?? 10; const highlightColor = styleProps?.highlightColor ?? "rgba(0, 120, 255, 0.15)"; const highlightBorderColor = styleProps?.highlightBorderColor ?? "rgba(0, 120, 255, 0.5)"; const highlightBorderWidth = styleProps?.highlightBorderWidth ?? 2; const highlightBorderRadius = styleProps?.highlightBorderRadius ?? 4; // Indent the line to match the node's nesting level so users can // visually distinguish drops at different tree depths. const leftOffset = level * indentationMultiplier; if (position === "inside") { return ( ); } // Ensure the circle isn't clipped at shallow indent levels const safeLeftOffset = Math.max(leftOffset, circleSize / 2); return ( ); } const styles = StyleSheet.create({ nodeExpandableArrowTouchable: { flex: 1 }, nodeCheckboxAndArrowRow: { flex: 1, flexDirection: "row", alignItems: "center", minWidth: "100%" }, dragContainer: { flex: 1, }, // Drop indicator styles (rendered by each node) dropHighlight: { position: "absolute", top: 0, bottom: 0, left: 0, right: 0, backgroundColor: "rgba(0, 120, 255, 0.15)", borderWidth: 2, borderColor: "rgba(0, 120, 255, 0.5)", borderRadius: 4, zIndex: 10, }, dropLineContainer: { position: "absolute", left: 0, right: 0, flexDirection: "row", alignItems: "center", height: 3, zIndex: 10, overflow: "visible", }, dropLineTop: { top: 0, }, dropLineBottom: { bottom: 0, }, dropLine: { flex: 1, height: 3, backgroundColor: "#0078FF", }, nodeOverflowVisible: { overflow: "visible", }, });