UNPKG

@deepdub/react-arborist

Version:
663 lines (562 loc) 17.7 kB
import { EditResult } from "../types/handlers"; import { Identity, IdObj } from "../types/utils"; import { TreeProps } from "../types/tree-props"; import { MutableRefObject } from "react"; import { Align, FixedSizeList, ListOnItemsRenderedProps } 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 { Store } from "redux"; import { createList } from "../data/create-list"; import { createIndex } from "../data/create-index"; const { safeRun, identify, identifyNull } = utils; export class TreeApi<T> { static editPromise: null | ((args: EditResult) => void); root: NodeApi<T>; visibleNodes: NodeApi<T>[]; visibleStartIndex: number = 0; visibleStopIndex: number = 0; idToIndex: { [id: string]: number }; constructor( public store: Store<RootState, Actions>, public props: TreeProps<T>, public list: MutableRefObject<FixedSizeList | null>, public listEl: MutableRefObject<HTMLDivElement | null> ) { /* Changes here must also be made in update() */ this.root = createRoot<T>(this); this.visibleNodes = createList<T>(this); this.idToIndex = createIndex(this.visibleNodes); } /* Changes here must also be made in constructor() */ update(props: TreeProps<T>) { this.props = props; this.root = createRoot<T>(this); this.visibleNodes = createList<T>(this); this.idToIndex = createIndex(this.visibleNodes); } /* 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; } get rowHeight() { return this.props.rowHeight ?? 24; } get overscanCount() { return this.props.overscanCount ?? 1; } get searchTerm() { return (this.props.searchTerm || "").trim(); } get matchFn() { const match = this.props.searchMatch ?? ((node, term) => { const string = JSON.stringify( Object.values(node.data as { [k: string]: unknown }) ); return string.toLocaleLowerCase().includes(term.toLocaleLowerCase()); }); return (node: NodeApi<T>) => match(node, this.searchTerm); } accessChildren(data: T) { const get = this.props.childrenAccessor || "children"; return utils.access<readonly T[] | undefined>(data, get) ?? null; } accessId(data: T) { const get = this.props.idAccessor || "id"; const id = utils.access<string>(data, get); if (!id) throw new Error( "Data must contain an 'id' property or props.idAccessor must return a string" ); return id; } /* 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<T> | null { if (!id) return null; if (id in this.idToIndex) return this.visibleNodes[this.idToIndex[id]] || null; else return null; } at(index: number): NodeApi<T> | 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: string | null | IdObj) { const key = utils.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: string | IdObj | null | string[] | IdObj[]) { if (!node) return; const idents = Array.isArray(node) ? node : [node]; const ids = idents.map(identify); const nodes = ids.map((id) => this.get(id)!).filter((n) => !!n); await safeRun(this.props.onDelete, { nodes, ids }); } edit(node: string | IdObj): Promise<EditResult> { const id = identify(node); this.resolveEdit({ cancelled: true }); this.scrollTo(id); this.dispatch(edit(id)); return new Promise((resolve) => { TreeApi.editPromise = resolve; }); } async submit(identity: Identity, value: string) { if (!identity) return; const id = identify(identity); await safeRun(this.props.onRename, { id, name: value, node: this.get(id)!, }); this.dispatch(edit(null)); this.resolveEdit({ cancelled: false, value }); setTimeout(() => this.onFocus()); // Return focus to element; } reset() { this.dispatch(edit(null)); this.resolveEdit({ cancelled: true }); setTimeout(() => this.onFocus()); // Return focus to element; } activate(id: string | IdObj | null) { const node = this.get(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, 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(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, opts: { align?: Align; focus?: boolean } = {}) { if (!node) return; const changeFocus = opts.focus !== false; const id = identify(node); if (changeFocus) this.dispatch(focus(id)); this.dispatch(selection.only(id)); this.dispatch(selection.anchor(id)); this.dispatch(selection.mostRecent(id)); this.scrollTo(id, opts.align); if (this.focusedNode && changeFocus) { safeRun(this.props.onFocus, this.focusedNode); } safeRun(this.props.onSelect, this.selectedNodes); } deselect(node: Identity) { if (!node) return; const id = identify(node); this.dispatch(selection.remove(id)); } selectMulti(identity: Identity) { const node = this.get(identifyNull(identity)); if (!node) return; this.dispatch(focus(node.id)); this.dispatch(selection.add(node.id)); this.dispatch(selection.anchor(node.id)); this.dispatch(selection.mostRecent(node.id)); this.scrollTo(node); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); safeRun(this.props.onSelect, this.selectedNodes); } selectContiguous(identity: Identity) { if (!identity) return; const id = identify(identity); const { anchor, mostRecent } = this.state.nodes.selection; this.dispatch(focus(id)); this.dispatch(selection.remove(this.nodesBetween(anchor, mostRecent))); this.dispatch(selection.add(this.nodesBetween(anchor, identifyNull(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() { this.setSelection({ ids: [], anchor: null, mostRecent: null }); safeRun(this.props.onSelect, this.selectedNodes); } selectAll() { this.setSelection({ ids: Object.keys(this.idToIndex), anchor: this.firstNode, mostRecent: this.lastNode, }); this.dispatch(focus(this.lastNode?.id)); if (this.focusedNode) safeRun(this.props.onFocus, this.focusedNode); safeRun(this.props.onSelect, this.selectedNodes); } setSelection(args: { ids: (IdObj | string)[] | null; anchor: IdObj | string | null; mostRecent: IdObj | string | null; }) { const ids = new Set(args.ids?.map(identify)); const anchor = identifyNull(args.anchor); const mostRecent = 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<T>[]; } 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; } } hideCursor() { this.dispatch(dnd.cursor({ type: "none" })); } showCursor(cursor: Cursor) { this.dispatch(dnd.cursor(cursor)); } /* Visibility */ open(identity: Identity) { const id = identifyNull(identity); if (!id) return; if (this.isOpen(id)) return; this.dispatch(visibility.open(id, this.isFiltered)); safeRun(this.props.onToggle, id); } close(identity: Identity) { const id = identifyNull(identity); if (!id) return; if (!this.isOpen(id)) return; this.dispatch(visibility.close(id, this.isFiltered)); safeRun(this.props.onToggle, id); } toggle(identity: Identity) { const id = identifyNull(identity); if (!id) return; return this.isOpen(id) ? this.close(id) : this.open(id); } openParents(identity: Identity) { const id = identifyNull(identity); if (!id) return; const node = utils.dfs(this.root, id); let parent = node?.parent; while (parent) { this.open(parent.id); parent = parent.parent; } } openSiblings(node: NodeApi<T>) { 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) { isOpen ? this.close(sibling.id) : this.open(sibling.id); } } this.scrollTo(this.focusedNode); } } openAll() { utils.walk(this.root, (node) => { if (node.isInternal) node.open(); }); } closeAll() { utils.walk(this.root, (node) => { if (node.isInternal) node.close(); }); } /* Scrolling */ scrollTo(identity: Identity, align: Align = "smart") { if (!identity) return; const id = 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); }) .catch(() => { // Id: ${id} never appeared in the list. }); } /* State Checks */ get isEditing() { return this.state.nodes.edit.id !== null; } get isFiltered() { return !!this.props.searchTerm?.trim(); } 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) { const check = this.props.disableEdit || (() => false); return !utils.access(data, check) ?? true; } isDraggable(data: T) { const check = this.props.disableDrag || (() => false); return !utils.access(data, check) ?? true; } isDragging(node: string | IdObj | null) { const id = 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<T>) { return this.matchFn(node); } willReceiveDrop(node: string | IdObj | null) { const id = 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; } }