import * as React from "react"; import trieMemoize from "trie-memoize"; import type { FileTree } from "./file-tree"; import { SubjectSet } from "./observable-data"; import { nodesById } from "./tree/nodes-by-id"; import type { Subject } from "./tree/subject"; import { useVisibleNodes } from "./use-visible-nodes"; /** * A hook for adding select and multi-select to the file tree. * * @param fileTree - A file tree * @param nodes - When using a hook like `useFilter` you can supply the filtered list of * nodes to this option. By default, `useVirtualize()` uses the nodes returned by * `useVisibleNodes()` */ export function useSelections( fileTree: FileTree, nodes?: number[] ): UseSelectionsPlugin { const visibleNodes_ = useVisibleNodes(fileTree); const visibleNodes = nodes ?? visibleNodes_; const prevSelectionsSet = React.useRef | null>(null); const selectionsSet = createSelectionsSet(fileTree, nodes ?? emptyArray); React.useEffect(() => { if (prevSelectionsSet.current) { for (const nodeId of prevSelectionsSet.current) { selectionsSet.add(nodeId); } } prevSelectionsSet.current = selectionsSet; }, [selectionsSet]); return { didChange: selectionsSet.didChange, get head() { return selectionsSet.head; }, get tail() { return selectionsSet.tail; }, getProps(nodeId: number) { return createProps(selectionsSet, visibleNodes, nodeId); }, select(...nodeIds: number[]) { for (const nodeId of nodeIds) { selectionsSet.add(nodeId); } }, deselect(...nodeIds: number[]) { for (const nodeId of nodeIds) { selectionsSet.delete(nodeId); } }, clear() { selectionsSet.clear(); }, narrow: function* () { // Remove child nodes from selections if their parent is already selected for (const nodeId of selectionsSet) { const node = nodesById[nodeId]; if (node) { let parentId = node.parentId; while (parentId > -1) { if (selectionsSet.has(parentId)) { break; } const parentNode = nodesById[parentId]; if (!parentNode) { break; } parentId = parentNode.parentId; } if (parentId === -1) { yield nodeId; } } } }, }; } const emptyArray: number[] = []; const createProps = trieMemoize( [WeakMap, WeakMap, Map], ( selectionsSet: SubjectRange, visibleNodes: number[], nodeId: number ): SelectionsProps => { return { onClick(event) { if (!visibleNodes) { return; } if (event.shiftKey) { const { head, tail } = selectionsSet; const headIndex = !head ? -1 : visibleNodes.indexOf(head); const tailIndex = !tail ? -1 : visibleNodes.indexOf(tail); const nodeIndex = visibleNodes.indexOf(nodeId); const direction = tailIndex > nodeIndex ? -1 : 1; // Select range let selectStart = tailIndex; let selectEnd = nodeIndex; if (direction === 1) { selectStart = tailIndex; } else { selectStart = nodeIndex; selectEnd = tailIndex; } if (selectStart > -1 && selectEnd > -1) { for (let i = selectStart; i <= selectEnd; i++) { const node = visibleNodes[i]; selectionsSet.add(node); } } // Deselect range let deselectStart = -1; let deselectEnd = -1; if (direction === 1 && headIndex > tailIndex) { deselectStart = tailIndex; deselectEnd = Math.min(headIndex, nodeIndex) - 1; } else if (direction === -1 && headIndex < tailIndex) { deselectStart = Math.max(headIndex, nodeIndex) + 1; deselectEnd = tailIndex; } if (deselectStart > -1 && deselectEnd > -1) { for (let i = deselectStart; i <= deselectEnd; i++) { const node = visibleNodes[i]; selectionsSet.delete(node); } } if (selectionsSet.head === null) { selectionsSet.head = nodeId; } } else if (event.metaKey) { if (selectionsSet.has(nodeId)) { selectionsSet.delete(nodeId); } else { selectionsSet.add(nodeId); } selectionsSet.head = nodeId; } else { selectionsSet.clear(); selectionsSet.add(nodeId); selectionsSet.head = nodeId; } selectionsSet.tail = nodeId; }, }; } ); const createSelectionsSet = trieMemoize( [WeakMap, WeakMap], (fileTree: FileTree, visibleNodes: number[]) => new SubjectRange() ); class SubjectRange extends SubjectSet { head: T | null = null; tail: T | null = null; add(value: T) { super.add(value); if (this.head === null) { this.head = value; } this.tail = value; return this; } delete(value: T) { const deleted = super.delete(value); return deleted; } clear() { super.clear(); this.head = null; this.tail = null; return this; } } export interface SelectionsProps { onClick: React.MouseEventHandler; } export interface UseSelectionsPlugin { /** * A subject that you can use to observe to changes to selections. */ didChange: Subject>; /** * Get the React props for a given node ID. * * @param nodeId - A node ID */ getProps(nodeId: number): SelectionsProps; /** * The head of the selections list */ get head(): number | null; /** * The tail of the selections list */ get tail(): number | null; /** * Select given node ids * * @param nodeIds - Node IDs */ select(...nodeIds: number[]): void; /** * Deselect given node ids * * @param nodeIds - Node IDs */ deselect(...nodeIds: number[]): void; /** * Clear all of the selections */ clear(): void; /** * A utility function that yields nodes from a set of selections if they * don't have a parent node in the set. * * @yields {number} - A node id */ narrow(): Generator; }