import { EditResult } from "../types/handlers"; import { BoolFunc, Identity, IdObj } from "../types/utils"; import { TreeProps } from "../types/tree-props"; import { MutableRefObject } from "react"; import { Align, FixedSizeList, ListOnItemsRenderedProps, VariableSizeList } from "react-window"; import * as utils from "../utils"; import { DefaultCursor } from "../components/default-cursor"; import { DefaultRow } from "../components/default-row"; import { DefaultNode } from "../components/default-node"; import { NodeApi } from "./node-api"; import { edit } from "../state/edit-slice"; import { Actions, RootState } from "../state/root-reducer"; import { focus, treeBlur } from "../state/focus-slice"; import { createRoot, ROOT_ID } from "../data/create-root"; import { actions as visibility } from "../state/open-slice"; import { actions as selection } from "../state/selection-slice"; import { actions as dnd } from "../state/dnd-slice"; import { DefaultDragPreview } from "../components/default-drag-preview"; import { DefaultContainer } from "../components/default-container"; import { Cursor } from "../dnd/compute-drop"; import type { DropResult } from "../dnd/drop-hook"; import { Store } from "redux"; import { createList } from "../data/create-list"; import { createIndex } from "../data/create-index"; const { safeRun } = utils; export class TreeApi { static editPromise: null | ((args: EditResult) => void); root: NodeApi; visibleNodes: NodeApi[]; /** Number of nodes matching the current searchTerm (0 when not filtered). * Computed alongside visibleNodes so reading it never re-traverses the tree. * See the filteredCount getter for the consumer-facing contract. */ private matchCount: number = 0; visibleStartIndex: number = 0; visibleStopIndex: number = 0; idToIndex: { [id: string]: number }; /* Memoized prefix-sum of row heights; only used for variable heights. */ private rowOffsets: number[] | null = null; constructor( public store: Store, public props: TreeProps, public list: MutableRefObject, public listEl: MutableRefObject, ) { /* Changes here must also be made in update() */ this.root = createRoot(this); const { list: visibleNodes, matchCount } = createList(this); this.visibleNodes = visibleNodes; this.matchCount = matchCount; this.idToIndex = createIndex(this.visibleNodes); } /* Changes here must also be made in constructor() */ update(props: TreeProps) { this.props = props; this.root = createRoot(this); const { list: visibleNodes, matchCount } = createList(this); this.visibleNodes = visibleNodes; this.matchCount = matchCount; this.idToIndex = createIndex(this.visibleNodes); this.rowOffsets = null; /* Variable-height mode renders a VariableSizeList, which caches item measurements by index and never invalidates them on its own. When the visible nodes change (insert/remove/reorder), those cached sizes belong to the wrong rows, so drop them. Fixed-height mode renders a FixedSizeList (no cache, nothing to reset). update() runs during render, so pass shouldForceUpdate=false: the in-progress render repaints the list and a forceUpdate here would warn about setting state mid-render. */ const list = this.list.current; if (list && "resetAfterIndex" in list) { list.resetAfterIndex(0, false); } } /* Store helpers */ dispatch(action: Actions) { return this.store.dispatch(action); } get state() { return this.store.getState(); } get openState() { return this.state.nodes.open.unfiltered; } /* Tree Props */ get width() { return this.props.width ?? 300; } get height() { return this.props.height ?? 500; } get indent() { return this.props.indent ?? 24; } /** * The fixed row height. When a `rowHeight` function is supplied for variable * heights, this returns the default (24); use `rowHeightAt(index)` to get the * height of a specific row. */ get rowHeight() { return typeof this.props.rowHeight === "number" ? this.props.rowHeight : 24; } /** * The height of the row at `index`, evaluating the `rowHeight` function if * given. Falls back to the default height for an out-of-range index so this * never feeds an invalid `0` to react-window's `itemSize`. */ rowHeightAt = (index: number): number => { const rowHeight = this.props.rowHeight; if (typeof rowHeight === "function") { const node = this.at(index); return node ? rowHeight(node) : this.rowHeight; } return rowHeight ?? 24; }; /** The pixel offset of the top of the row at `index` from the top of the list. */ rowTopPosition = (index: number): number => { /* Fixed heights: O(1). */ if (typeof this.props.rowHeight !== "function") { return index * this.rowHeight; } /* Variable heights: O(1) amortized via a memoized prefix sum. */ const offsets = this.getRowOffsets(); const clamped = Math.max(0, Math.min(index, offsets.length - 1)); return offsets[clamped]; }; /** * Tell the underlying virtualized list to recompute row heights at and after * `index`. Call this if a `rowHeight` function's output changes for reasons * the tree can't observe (e.g. external state). */ redrawList = (afterIndex: number = 0) => { this.rowOffsets = null; /* Only the VariableSizeList (function rowHeight) caches measurements; a FixedSizeList has constant heights and nothing to recompute. */ const list = this.list.current; if (list && "resetAfterIndex" in list) { list.resetAfterIndex(Math.max(0, afterIndex)); } }; /** Lazily-built prefix sum where offsets[i] is the top of row i. */ private getRowOffsets(): number[] { if (this.rowOffsets) return this.rowOffsets; const offsets: number[] = [0]; for (let i = 0; i < this.visibleNodes.length; i++) { offsets.push(offsets[i] + this.rowHeightAt(i)); } this.rowOffsets = offsets; return offsets; } get overscanCount() { return this.props.overscanCount ?? 1; } get searchTerm() { return (this.props.searchTerm || "").trim(); } get matchFn() { const match = this.props.searchMatch ?? ((node, term) => { /* Search a node's own fields only. Including the children array would make every ancestor of a match match too, inflating filteredCount and matching folders on nested keys like "id" (#112). Ancestors are still kept in the filtered list to preserve the tree's structure. */ const children = this.accessChildren(node.data); const values = Object.values(node.data as { [k: string]: unknown }).filter( (value) => children === null || value !== children, ); const string = JSON.stringify(values); return string.toLocaleLowerCase().includes(term.toLocaleLowerCase()); }); return (node: NodeApi) => match(node, this.searchTerm); } accessChildren(data: T) { const get = this.props.childrenAccessor || "children"; return utils.access(data, get) ?? null; } accessId(data: T) { const get = this.props.idAccessor || "id"; const id = utils.access(data, get); if (!id) throw new Error( "Data must contain an 'id' property or props.idAccessor must return a string", ); return id; } /** * Resolve an identifier to a node id. Public methods accept an id string, a * NodeApi, or the raw row data; this is the one place that turns any of those * into the string id used internally. Raw data is run through the configured * `idAccessor` so a custom accessor (e.g. `uuid`) is honored everywhere, not * just where nodes were built. A NodeApi already carries its accessor-derived * `id`, so it is used directly rather than re-accessed (the accessor reads the * underlying data, which a NodeApi does not expose under that key). Unlike * `accessId`, an unresolved id comes back as `undefined` rather than throwing, * preserving the previous behavior of the `id`-only lookup. */ identify(identity: string | IdObj | T): string { if (typeof identity === "string") return identity; if (identity instanceof NodeApi) return identity.id; const get = this.props.idAccessor || "id"; return utils.access(identity, get); } identifyNull(identity: Identity | T): string | null { if (identity === null || identity === undefined) return null; return this.identify(identity); } /* Node Access */ get firstNode() { return this.visibleNodes[0] ?? null; } get lastNode() { return this.visibleNodes[this.visibleNodes.length - 1] ?? null; } get focusedNode() { return this.get(this.state.nodes.focus.id) ?? null; } get mostRecentNode() { return this.get(this.state.nodes.selection.mostRecent) ?? null; } get nextNode() { const index = this.indexOf(this.focusedNode); if (index === null) return null; else return this.at(index + 1); } get prevNode() { const index = this.indexOf(this.focusedNode); if (index === null) return null; else return this.at(index - 1); } get(id: string | null): NodeApi | null { if (!id) return null; if (id in this.idToIndex) return this.visibleNodes[this.idToIndex[id]] || null; else return null; } at(index: number): NodeApi | null { return this.visibleNodes[index] || null; } nodesBetween(startId: string | null, endId: string | null) { if (startId === null || endId === null) return []; const index1 = this.indexOf(startId) ?? 0; const index2 = this.indexOf(endId); if (index2 === null) return []; const start = Math.min(index1, index2); const end = Math.max(index1, index2); return this.visibleNodes.slice(start, end + 1); } indexOf(id: Identity | T) { const key = this.identifyNull(id); if (!key) return null; return this.idToIndex[key]; } /* Data Operations */ get editingId() { return this.state.nodes.edit.id; } createInternal() { return this.create({ type: "internal" }); } createLeaf() { return this.create({ type: "leaf" }); } async create( opts: { type?: "internal" | "leaf"; parentId?: null | string; index?: null | number; } = {}, ) { const parentId = opts.parentId === undefined ? utils.getInsertParentId(this) : opts.parentId; const index = opts.index ?? utils.getInsertIndex(this); const type = opts.type ?? "leaf"; const data = await safeRun(this.props.onCreate, { type, parentId, index, parentNode: this.get(parentId), }); if (data) { this.focus(data); setTimeout(() => { this.edit(data).then(() => { this.select(data); this.activate(data); }); }); } } async delete(node: Identity | T | (string | IdObj | T)[]) { if (!node) return; const idents = Array.isArray(node) ? node : [node]; const ids = idents.map((i) => this.identify(i)); const nodes = ids.map((id) => this.get(id)!).filter((n) => !!n); /* Guard against Math.min(...[]) === Infinity when no ids resolve to nodes. */ const fromIndex = nodes.length ? Math.min(...nodes.map((n) => n.rowIndex ?? 0)) : 0; await safeRun(this.props.onDelete, { nodes, ids }); this.redrawList(fromIndex); } edit(node: string | IdObj | T): Promise { const id = this.identify(node); this.resolveEdit({ cancelled: true }); this.scrollTo(id); this.dispatch(edit(id)); this.redrawList(this.get(id)?.rowIndex ?? 0); return new Promise((resolve) => { TreeApi.editPromise = resolve; }); } async submit(identity: Identity | T, value: string) { if (!identity) return; const id = this.identify(identity); await safeRun(this.props.onRename, { id, name: value, node: this.get(id)!, }); this.dispatch(edit(null)); this.resolveEdit({ cancelled: false, value }); this.redrawList(this.get(id)?.rowIndex ?? 0); setTimeout(() => this.onFocus()); // Return focus to element; } reset() { this.dispatch(edit(null)); this.resolveEdit({ cancelled: true }); this.redrawList(); setTimeout(() => this.onFocus()); // Return focus to element; } activate(id: Identity | T) { const node = this.get(this.identifyNull(id)); if (!node) return; safeRun(this.props.onActivate, node); } private resolveEdit(value: EditResult) { const resolve = TreeApi.editPromise; if (resolve) resolve(value); TreeApi.editPromise = null; } /* Focus and Selection */ get selectedIds() { return this.state.nodes.selection.ids; } get selectedNodes() { let nodes = []; for (let id of Array.from(this.selectedIds)) { const node = this.get(id); if (node) nodes.push(node); } return nodes; } focus(node: Identity | T, opts: { scroll?: boolean } = {}) { if (!node) return; /* Focus is responsible for scrolling, while selection is * responsible for focus. If selectionFollowsFocus, then * just select it. */ if (this.props.selectionFollowsFocus) { this.select(node); } else { this.dispatch(focus(this.identify(node))); if (opts.scroll !== false) this.scrollTo(node); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); } } pageUp() { const start = this.visibleStartIndex; const stop = this.visibleStopIndex; const page = stop - start; let index = this.focusedNode?.rowIndex ?? 0; if (index > start) { index = start; } else { index = Math.max(start - page, 0); } this.focus(this.at(index)); } pageDown() { const start = this.visibleStartIndex; const stop = this.visibleStopIndex; const page = stop - start; let index = this.focusedNode?.rowIndex ?? 0; if (index < stop) { index = stop; } else { index = Math.min(index + page, this.visibleNodes.length - 1); } this.focus(this.at(index)); } select(node: Identity | T, opts: { align?: Align; focus?: boolean } = {}) { if (!node) return; const changeFocus = opts.focus !== false; const id = this.identify(node); if (changeFocus) this.dispatch(focus(id)); if (this.get(id)?.isSelectable) { this.setSelection({ ids: [id], anchor: id, mostRecent: id, }); } this.scrollTo(id, opts.align); if (this.focusedNode && changeFocus) { safeRun(this.props.onFocus, this.focusedNode); } } deselect(node: Identity | T) { if (!node) return; const id = this.identify(node); this.dispatch(selection.remove(id)); safeRun(this.props.onSelect, this.selectedNodes); } selectMulti(identity: Identity | T, opts: { align?: Align; focus?: boolean } = {}) { const node = this.get(this.identifyNull(identity)); if (!node) return; const changeFocus = opts.focus !== false; if (changeFocus) this.dispatch(focus(node.id)); if (node.isSelectable) { this.dispatch(selection.add(node.id)); this.dispatch(selection.anchor(node.id)); this.dispatch(selection.mostRecent(node.id)); } this.scrollTo(node, opts.align); if (this.focusedNode && changeFocus) { safeRun(this.props.onFocus, this.focusedNode); } safeRun(this.props.onSelect, this.selectedNodes); } selectContiguous(identity: Identity | T) { if (!identity) return; const id = this.identify(identity); this.dispatch(focus(id)); if (this.get(id)?.isSelectable) { const { anchor, mostRecent } = this.state.nodes.selection; const selectableNodes = this.filterSelectableNodes( this.nodesBetween(anchor, this.identifyNull(id)), ); this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent).map((n) => n.id))); this.dispatch(selection.add(selectableNodes.map((n) => n.id))); this.dispatch(selection.mostRecent(id)); } this.scrollTo(id); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); safeRun(this.props.onSelect, this.selectedNodes); } deselectAll() { // setSelection fires onSelect; don't fire it again here (see #332). this.setSelection({ ids: [], anchor: null, mostRecent: null }); } selectAll() { const allSelectableNodes = this.filterSelectableNodes(Object.keys(this.idToIndex)); // setSelection fires onSelect; don't fire it again here (see #332). this.setSelection({ ids: allSelectableNodes, anchor: allSelectableNodes[0] ?? null, mostRecent: allSelectableNodes[allSelectableNodes.length - 1] ?? null, }); this.dispatch(focus(this.lastNode?.id)); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); } private filterSelectableNodes(nodes: (IdObj | string)[]) { return nodes .map((n) => this.get(this.identify(n))) .filter((n): n is NodeApi => !!n && n.isSelectable); } setSelection(args: { ids: (IdObj | string | T)[] | null; anchor: Identity | T; mostRecent: Identity | T; }) { const ids = new Set(args.ids?.map((i) => this.identify(i))); const anchor = this.identifyNull(args.anchor); const mostRecent = this.identifyNull(args.mostRecent); this.dispatch(selection.set({ ids, anchor, mostRecent })); safeRun(this.props.onSelect, this.selectedNodes); } /* Drag and Drop */ get cursorParentId() { const { cursor } = this.state.dnd; switch (cursor.type) { case "highlight": return cursor.id; default: return null; } } get cursorOverFolder() { return this.state.dnd.cursor.type === "highlight"; } get dragNodes() { return this.state.dnd.dragIds.map((id) => this.get(id)).filter((n) => !!n) as NodeApi[]; } get dragNode() { return this.get(this.state.nodes.drag.id); } get dragDestinationParent() { return this.get(this.state.nodes.drag.destinationParentId); } get dragDestinationIndex() { return this.state.nodes.drag.destinationIndex; } canDrop() { if (this.isFiltered) return false; const parentNode = this.get(this.state.dnd.parentId) ?? this.root; const dragNodes = this.dragNodes; const isDisabled = this.props.disableDrop; for (const drag of dragNodes) { if (!drag) return false; if (!parentNode) return false; if (drag.isInternal && utils.isDescendant(parentNode, drag)) return false; } // Allow the user to insert their own logic if (typeof isDisabled == "function") { return !isDisabled({ parentNode, dragNodes: this.dragNodes, index: this.state.dnd.index || 0, }); } else if (typeof isDisabled == "string") { // @ts-ignore return !parentNode.data[isDisabled]; } else if (typeof isDisabled === "boolean") { return !isDisabled; } else { return true; } } /* Called by the drop hooks on every hover. Records the computed target for the drop guard (canDrop() and drop() read state.dnd.parentId), then — only when that target is actually droppable — surfaces it to consumers (willReceiveDrop, dragDestinationParent) and shows the cursor. When it isn't droppable, the consumer-facing destination and the cursor are both cleared so they never disagree with canDrop() (#247); the guard still sees the real target, so releasing over an invalid spot is rejected rather than falling back to a root drop. */ hover(drop: DropResult | null, cursor: Cursor | null) { if (drop) this.dispatch(dnd.hovering(drop.parentId, drop.index)); if (drop && this.canDrop()) { this.dispatch(dnd.setDestination(drop.parentId, drop.index)); if (cursor) this.showCursor(cursor); } else { this.dispatch(dnd.setDestination(null, null)); this.hideCursor(); } } drop() { const { parentId, index, dragIds } = this.state.dnd; safeRun(this.props.onMove, { dragIds, parentId: parentId === ROOT_ID ? null : parentId, index: index === null ? 0 : index, // When it's null it was dropped over a folder dragNodes: this.dragNodes, parentNode: this.get(parentId), }); this.open(parentId); } hideCursor() { this.dispatch(dnd.cursor({ type: "none" })); } showCursor(cursor: Cursor) { this.dispatch(dnd.cursor(cursor)); } /* Visibility */ open(identity: Identity | T, redraw: boolean = true) { const id = this.identifyNull(identity); if (!id) return; if (this.isOpen(id)) return; this.dispatch(visibility.open(id, this.isFiltered)); if (redraw) this.redrawList(this.get(id)?.rowIndex ?? 0); safeRun(this.props.onToggle, id); } close(identity: Identity | T, redraw: boolean = true) { const id = this.identifyNull(identity); if (!id) return; if (!this.isOpen(id)) return; this.dispatch(visibility.close(id, this.isFiltered)); if (redraw) this.redrawList(this.get(id)?.rowIndex ?? 0); safeRun(this.props.onToggle, id); } toggle(identity: Identity | T) { const id = this.identifyNull(identity); if (!id) return; return this.isOpen(id) ? this.close(id) : this.open(id); } openParents(identity: Identity | T) { const id = this.identifyNull(identity); if (!id) return; const node = utils.dfs(this.root, id); let parent = node?.parent; while (parent) { this.open(parent.id, false); parent = parent.parent; } this.redrawList(); } openSiblings(node: NodeApi) { const parent = node.parent; if (!parent) { this.toggle(node.id); } else if (parent.children) { const isOpen = node.isOpen; for (let sibling of parent.children) { if (sibling.isInternal) { if (isOpen) this.close(sibling.id, false); else this.open(sibling.id, false); } } this.redrawList(); this.scrollTo(this.focusedNode); } } openAll() { utils.walk(this.root, (node) => { if (node.isInternal) this.open(node.id, false); }); this.redrawList(); } closeAll() { utils.walk(this.root, (node) => { if (node.isInternal) this.close(node.id, false); }); this.redrawList(); } /* Scrolling */ scrollTo(identity: Identity | T, align: Align = "smart") { if (!identity) return; const id = this.identify(identity); this.openParents(id); return utils .waitFor(() => id in this.idToIndex) .then(() => { const index = this.idToIndex[id]; if (index === undefined) return; this.list.current?.scrollToItem(index, align); /* react-window only scrolls vertically. A deeply nested node is indented by level * indent and can sit past the right edge when rows overflow horizontally, so bring it into view ourselves (#220). */ this.scrollToNodeHorizontally(this.get(id)); }) .catch(() => { // Id: ${id} never appeared in the list. }); } /** * Horizontally scroll the list so the node's indented content is in view. * A no-op when the list doesn't overflow horizontally (the common case), so * it never disturbs scrolling for trees that fit their width. */ private scrollToNodeHorizontally(node: NodeApi | null) { const el = this.listEl.current; if (!node || !el) return; const maxScroll = el.scrollWidth - el.clientWidth; if (maxScroll <= 0) return; // nothing to scroll const left = node.level * this.indent; const viewLeft = el.scrollLeft; const viewRight = el.scrollLeft + el.clientWidth; /* The visible range is half-open [viewLeft, viewRight): a pixel at viewRight is already clipped. Only move when the node's indentation falls outside it, aligning its content start to the left edge so the label is revealed, clamped to the list's scrollable range. */ if (left < viewLeft || left >= viewRight) { el.scrollLeft = Math.max(0, Math.min(left, maxScroll)); } } /** * Scroll the list vertically to an exact pixel offset from the top. This is * the offset-based counterpart to scrollTo(), handy for saving and restoring * a scroll position (#194). Negative values are clamped to the top; react- * window clamps the upper bound to the scrollable range. */ scrollToOffset(offset: number) { /* Coerce non-finite offsets (NaN/Infinity, easy to get from malformed persisted state) to the top rather than forwarding them to the list. */ const safe = Number.isFinite(offset) ? Math.max(0, offset) : 0; this.list.current?.scrollTo(safe); } /** The list's current vertical scroll offset, in pixels from the top. */ get scrollOffset(): number { return this.listEl.current?.scrollTop ?? 0; } /* State Checks */ get isEditing() { return this.state.nodes.edit.id !== null; } get isFiltered() { return !!this.props.searchTerm?.trim(); } /** The number of nodes matching the current search term, counted across the * whole tree regardless of which folders are open. Returns 0 when there is no * active search. Consumers use this to render match counts or a "no results" * message (#112, #256). Ancestors shown only to keep the tree's structure * intact are not counted. The count is computed once when the visible list is * built (see createList), so reading it never re-traverses the tree. */ get filteredCount() { return this.matchCount; } get hasFocus() { return this.state.nodes.focus.treeFocused; } get hasNoSelection() { return this.state.nodes.selection.ids.size === 0; } get hasOneSelection() { return this.state.nodes.selection.ids.size === 1; } get hasMultipleSelections() { return this.state.nodes.selection.ids.size > 1; } isSelected(id?: string) { if (!id) return false; return this.state.nodes.selection.ids.has(id); } isOpen(id?: string) { if (!id) return false; if (id === ROOT_ID) return true; const def = this.props.openByDefault ?? true; if (this.isFiltered) { return this.state.nodes.open.filtered[id] ?? true; // Filtered folders are always opened by default } else { return this.state.nodes.open.unfiltered[id] ?? def; } } isEditable(data: T) { return this.isActionPossible(data, this.props.disableEdit); } isDraggable(data: T) { return this.isActionPossible(data, this.props.disableDrag); } isSelectable(data: T) { return this.isActionPossible(data, this.props.disableSelect); } private isActionPossible(data: T, disabler: string | boolean | BoolFunc = () => false) { return !utils.access(data, disabler); } isDragging(node: Identity | T) { const id = this.identifyNull(node); if (!id) return false; return this.state.nodes.drag.id === id; } isFocused(id: string) { return this.hasFocus && this.state.nodes.focus.id === id; } isMatch(node: NodeApi) { return this.matchFn(node); } willReceiveDrop(node: Identity | T) { const id = this.identifyNull(node); if (!id) return false; const { destinationParentId, destinationIndex } = this.state.nodes.drag; return id === destinationParentId && destinationIndex === null; } /* Tree Event Handlers */ onFocus() { const node = this.focusedNode || this.firstNode; if (node) this.dispatch(focus(node.id)); } onBlur() { this.dispatch(treeBlur()); } onItemsRendered(args: ListOnItemsRenderedProps) { this.visibleStartIndex = args.visibleStartIndex; this.visibleStopIndex = args.visibleStopIndex; } /* Get Renderers */ get renderContainer() { return this.props.renderContainer || DefaultContainer; } get renderRow() { return this.props.renderRow || DefaultRow; } get renderNode() { return this.props.children || DefaultNode; } get renderDragPreview() { return this.props.renderDragPreview || DefaultDragPreview; } get renderCursor() { return this.props.renderCursor || DefaultCursor; } }