UNPKG

wunderbaum

Version:

JavaScript tree/grid/treegrid control.

1,716 lines (1,576 loc) 89.5 kB
/*! * wunderbaum.ts * * A treegrid control. * * Copyright (c) 2021-2025, Martin Wendt (https://wwWendt.de). * https://github.com/mar10/wunderbaum * * Released under the MIT license. * @version @VERSION * @date @DATE */ // import "./wunderbaum.scss"; import * as util from "./util"; import { FilterExtension } from "./wb_ext_filter"; import { KeynavExtension } from "./wb_ext_keynav"; import { LoggerExtension } from "./wb_ext_logger"; import { DndExtension } from "./wb_ext_dnd"; import { GridExtension } from "./wb_ext_grid"; import { ExtensionsDict, WunderbaumExtension } from "./wb_extension_base"; import { AddChildrenOptions, ApplyCommandOptions, ApplyCommandType, ChangeType, ColumnDefinitionList, DynamicBoolOption, DynamicCheckboxOption, DynamicIconOption, DynamicStringOption, DynamicTooltipOption, ExpandAllOptions, FilterModeType, FilterNodesOptions, IconMapType, GetStateOptions, MatcherCallback, NavigationType, NavModeEnum, NodeFilterCallback, NodeRegion, NodeStatusType, NodeStringCallback, NodeToDictCallback, NodeTypeDefinitionMap, NodeVisitCallback, RenderFlag, ScrollToOptions, SetActiveOptions, SetColumnOptions, SetStateOptions, SetStatusOptions, SortByPropertyOptions, SortCallback, SourceType, TreeStateDefinition, UpdateOptions, VisitRowsOptions, WbEventInfo, WbNodeData, } from "./types"; import { DEFAULT_DEBUGLEVEL, iconMaps, makeNodeTitleStartMatcher, nodeTitleSorter, RENDER_MAX_PREFETCH, DEFAULT_ROW_HEIGHT, TEST_IMG, } from "./common"; import { WunderbaumNode } from "./wb_node"; import { Deferred } from "./deferred"; import { EditExtension } from "./wb_ext_edit"; import { WunderbaumOptions } from "./wb_options"; import { DebouncedFunction } from "./debounce"; class WbSystemRoot extends WunderbaumNode { constructor(tree: Wunderbaum) { super(tree, <WunderbaumNode>(<unknown>null), { key: "__root__", title: tree.id, }); } toString() { return `WbSystemRoot@${this.key}<'${this.tree.id}'>`; } } /** * A persistent plain object or array. * * See also {@link WunderbaumOptions}. */ export class Wunderbaum { protected static sequence = 0; protected enabled = true; /** Wunderbaum release version number "MAJOR.MINOR.PATCH". */ public static version: string = "@VERSION"; // Set to semver by 'grunt release' /** The invisible root node, that holds all visible top level nodes. */ public readonly root: WunderbaumNode; /** Unique tree ID as passed to constructor. Defaults to `"wb_SEQUENCE"`. */ public readonly id: string; /** The `div` container element that was passed to the constructor. */ public readonly element: HTMLDivElement; /** The `div.wb-header` element if any. */ public readonly headerElement: HTMLDivElement; /** The `div.wb-list-container` element that contains the `nodeListElement`. */ public readonly listContainerElement: HTMLDivElement; /** The `div.wb-node-list` element that contains all visible div.wb-row child elements. */ public readonly nodeListElement: HTMLDivElement; /** Contains additional data that was sent as response to an Ajax source load request. */ public readonly data: { [key: string]: any } = {}; protected readonly _updateViewportThrottled: DebouncedFunction<() => void>; protected extensionList: WunderbaumExtension<any>[] = []; protected extensions: ExtensionsDict = {}; /** Merged options from constructor args and tree- and extension defaults. */ public options: WunderbaumOptions; protected keyMap = new Map<string, WunderbaumNode>(); protected refKeyMap = new Map<string, Set<WunderbaumNode>>(); protected treeRowCount = 0; protected _disableUpdateCount = 0; protected _disableUpdateIgnoreCount = 0; protected _activeNode: WunderbaumNode | null = null; protected _focusNode: WunderbaumNode | null = null; /** Currently active node if any. * Use {@link WunderbaumNode.setActive|setActive} to modify. */ public get activeNode() { // Check for deleted node, i.e. node.tree === null return this._activeNode?.tree ? this._activeNode : null; } /** Current node hat has keyboard focus if any. * Use {@link WunderbaumNode.setFocus|setFocus()} to modify. */ public get focusNode() { // Check for deleted node, i.e. node.tree === null return this._focusNode?.tree ? this._focusNode : null; } /** Shared properties, referenced by `node.type`. */ public types: NodeTypeDefinitionMap = {}; /** List of column definitions. */ public columns: ColumnDefinitionList = []; // any[] = []; /** Show/hide a checkbox or radiobutton. */ public checkbox?: DynamicCheckboxOption; /** Show/hide a node icon. */ public icon?: DynamicIconOption; /** Show/hide a tooltip for the node icon. */ public iconTooltip?: DynamicStringOption; /** Show/hide a tooltip. */ public tooltip?: DynamicTooltipOption; /** Define a node checkbox as readonly. */ public unselectable?: DynamicBoolOption; protected _columnsById: { [key: string]: any } = {}; protected resizeObserver: ResizeObserver; // Modification Status protected pendingChangeTypes: Set<RenderFlag> = new Set(); /** A Promise that is resolved when the tree was initialized (similar to `init(e)` event). */ public readonly ready: Promise<any>; /** Expose some useful methods of the util.ts module as `Wunderbaum.util`. */ public static util = util; /** Expose some useful methods of the util.ts module as `tree._util`. */ public _util = util; // --- SELECT --- // /** @internal */ // public selectRangeAnchor: WunderbaumNode | null = null; // --- BREADCRUMB --- /** Filter options (used as defaults for calls to {@link Wunderbaum.filterNodes} ) */ public breadcrumb: HTMLElement | null = null; // --- FILTER --- /** Filter options (used as defaults for calls to {@link Wunderbaum.filterNodes} ) */ public filterMode: FilterModeType = null; // --- KEYNAV --- /** @internal Use `setColumn()`/`getActiveColElem()` to access. */ public activeColIdx = 0; /** @internal */ public _cellNavMode = false; /** @internal */ public lastQuicksearchTime = 0; /** @internal */ public lastQuicksearchTerm = ""; // --- EDIT --- protected lastClickTime = 0; constructor(options: WunderbaumOptions) { const opts = (this.options = util.extend( { id: null, source: null, // URL for GET/PUT, Ajax options, or callback element: null, // <div class="wunderbaum"> debugLevel: DEFAULT_DEBUGLEVEL, // 0:quiet, 1:errors, 2:warnings, 3:info, 4:verbose header: null, // Show/hide header (pass bool or string) // headerHeightPx: ROW_HEIGHT, rowHeightPx: DEFAULT_ROW_HEIGHT, iconMap: "bootstrap", columns: null, types: null, // escapeTitles: true, enabled: true, fixedCol: false, showSpinner: false, checkbox: false, minExpandLevel: 0, emptyChildListExpandable: false, // updateThrottleWait: 200, skeleton: false, connectTopBreadcrumb: null, selectMode: "multi", // SelectModeType // --- KeyNav --- navigationModeOption: null, // NavModeEnum, quicksearch: true, // --- Events --- iconBadge: null, change: null, // enhanceTitle: null, error: null, receive: null, // --- Strings --- strings: { loadError: "Error", loading: "Loading...", noData: "No data", breadcrumbDelimiter: " » ", queryResult: "Found ${matches} of ${count}", noMatch: "No results", matchIndex: "${match} of ${matches}", }, }, options )); const readyDeferred = new Deferred(); this.ready = readyDeferred.promise(); let readyOk = false; this.ready .then(() => { readyOk = true; try { this._callEvent("init"); } catch (error) { // We re-raise in the reject handler, but Chrome resets the stack // frame then, so we log it here: this.logError("Exception inside `init(e)` event:", error); } }) .catch((err) => { if (readyOk) { // Error occurred in `init` handler. We can re-raise, but Chrome // resets the stack frame. throw err; } else { // Error in load process this._callEvent("init", { error: err }); } }); this.id = opts.id || "wb_" + ++Wunderbaum.sequence; this.root = new WbSystemRoot(this); this._registerExtension(new KeynavExtension(this)); this._registerExtension(new EditExtension(this)); this._registerExtension(new FilterExtension(this)); this._registerExtension(new DndExtension(this)); this._registerExtension(new GridExtension(this)); this._registerExtension(new LoggerExtension(this)); this._updateViewportThrottled = util.adaptiveThrottle( this._updateViewportImmediately.bind(this), {} ); // --- Evaluate options this.columns = opts.columns; delete opts.columns; if (!this.columns || !this.columns.length) { const title = typeof opts.header === "string" ? opts.header : this.id; this.columns = [{ id: "*", title: title, width: "*" }]; } if (opts.types) { this.setTypes(opts.types, true); } delete opts.types; // --- Create Markup this.element = util.elemFromSelector<HTMLDivElement>(opts.element)!; util.assert(!!this.element, `Invalid 'element' option: ${opts.element}`); this.element.classList.add("wunderbaum"); if (!this.element.getAttribute("tabindex")) { this.element.tabIndex = 0; } if (opts.rowHeightPx !== DEFAULT_ROW_HEIGHT) { this.element.style.setProperty( "--wb-row-outer-height", opts.rowHeightPx + "px" ); this.element.style.setProperty( "--wb-row-inner-height", opts.rowHeightPx - 2 + "px" ); } // Attach tree instance to <div> (<any>this.element)._wb_tree = this; // Create header markup, or take it from the existing html this.headerElement = this.element.querySelector<HTMLDivElement>("div.wb-header")!; const wantHeader = opts.header == null ? this.columns.length > 1 : !!opts.header; if (this.headerElement) { // User existing header markup to define `this.columns` util.assert( !this.columns, "`opts.columns` must not be set if table markup already contains a header" ); this.columns = []; const rowElement = this.headerElement.querySelector<HTMLDivElement>("div.wb-row")!; for (const colDiv of rowElement.querySelectorAll("div")) { this.columns.push({ id: colDiv.dataset.id || `col_${this.columns.length}`, // id: colDiv.dataset.id || null, title: "" + colDiv.textContent, // text: "" + colDiv.textContent, width: "*", // TODO: read from header span }); } } else { // We need a row div, the rest will be computed from `this.columns` const coldivs = "<span class='wb-col'></span>".repeat( this.columns.length ); this.element.innerHTML = ` <div class='wb-header'> <div class='wb-row'> ${coldivs} </div> </div>`; if (!wantHeader) { const he = this.element.querySelector<HTMLDivElement>("div.wb-header")!; he.style.display = "none"; } } // this.element.innerHTML += ` <div class="wb-list-container"> <div class="wb-node-list"></div> </div>`; this.listContainerElement = this.element.querySelector<HTMLDivElement>( "div.wb-list-container" )!; this.nodeListElement = this.listContainerElement.querySelector<HTMLDivElement>( "div.wb-node-list" )!; this.headerElement = this.element.querySelector<HTMLDivElement>("div.wb-header")!; this.element.classList.toggle("wb-grid", this.columns.length > 1); if (this.options.connectTopBreadcrumb) { this.breadcrumb = util.elemFromSelector( this.options.connectTopBreadcrumb )!; util.assert( !this.breadcrumb || this.breadcrumb.innerHTML != null, `Invalid 'connectTopBreadcrumb' option: ${this.breadcrumb}.` ); this.breadcrumb.addEventListener("click", (e) => { // const node = Wunderbaum.getNode(e)!; const elem = e.target as HTMLElement; if (elem && elem.matches("a.wb-breadcrumb")) { const node = this.keyMap.get(elem.dataset.key!); node?.setActive(); e.preventDefault(); } }); } this._initExtensions(); // --- apply initial options ["enabled", "fixedCol"].forEach((optName) => { if (opts[optName] != null) { this.setOption(optName, opts[optName]); } }); // --- Load initial data if (opts.source) { if (opts.showSpinner) { this.nodeListElement.innerHTML = `<progress class='spinner'>${opts.strings.loading}</progress>`; } this.load(opts.source) .then(() => { // The source may have defined columns, so we may adjust the nav mode if (opts.navigationModeOption == null) { if (this.isGrid()) { this.setNavigationOption(NavModeEnum.cell); } else { this.setNavigationOption(NavModeEnum.row); } } else { this.setNavigationOption(opts.navigationModeOption); } this.update(ChangeType.structure, { immediate: true }); readyDeferred.resolve(); }) .catch((error) => { readyDeferred.reject(error); }) .finally(() => { this.element.querySelector("progress.spinner")?.remove(); this.element.classList.remove("wb-initializing"); }); } else { readyDeferred.resolve(); } // Async mode is sometimes required, because this.element.clientWidth // has a wrong value at start??? this.update(ChangeType.any); // --- Bind listeners this.element.addEventListener("scroll", (e: Event) => { // this.log(`scroll, scrollTop:${e.target.scrollTop}`, e); this.update(ChangeType.scroll); }); this.resizeObserver = new ResizeObserver((entries) => { // this.log("ResizeObserver: Size changed", entries); this.update(ChangeType.resize); }); this.resizeObserver.observe(this.element); util.onEvent(this.element, "click", ".wb-button,.wb-col-icon", (e) => { const info = Wunderbaum.getEventInfo(e); const command = (<HTMLElement>e.target)?.dataset?.command; this._callEvent("buttonClick", { event: e, info: info, command: command, }); }); util.onEvent(this.nodeListElement, "click", "div.wb-row", (e) => { const info = Wunderbaum.getEventInfo(e); const node = info.node; const mouseEvent = e as MouseEvent; // this.log("click", info); // if (this._selectRange(info) === false) { // return; // } if ( this._callEvent("click", { event: e, node: node, info: info }) === false ) { this.lastClickTime = Date.now(); return false; } if (node) { if (mouseEvent.ctrlKey) { node.toggleSelected(); return; } // Edit title if 'clickActive' is triggered: const trigger = this.getOption("edit.trigger"); const slowClickDelay = this.getOption("edit.slowClickDelay"); if ( trigger.indexOf("clickActive") >= 0 && info.region === "title" && node.isActive() && (!slowClickDelay || Date.now() - this.lastClickTime < slowClickDelay) ) { node.startEditTitle(); } if (info.colIdx >= 0) { node.setActive(true, { colIdx: info.colIdx, event: e }); } else { node.setActive(true, { event: e }); } if (info.region === NodeRegion.expander) { node.setExpanded(!node.isExpanded(), { scrollIntoView: options.scrollIntoViewOnExpandClick !== false, }); } else if (info.region === NodeRegion.checkbox) { node.toggleSelected(); } } this.lastClickTime = Date.now(); }); util.onEvent(this.nodeListElement, "dblclick", "div.wb-row", (e) => { const info = Wunderbaum.getEventInfo(e); const node = info.node; // this.log("dblclick", info, e); if ( this._callEvent("dblclick", { event: e, node: node, info: info }) === false ) { return false; } if ( node && info.colIdx === 0 && node.isExpandable() && info.region !== NodeRegion.expander ) { this._callMethod("edit._stopEditTitle"); node.setExpanded(!node.isExpanded()); } }); util.onEvent(this.element, "keydown", (e) => { const info = Wunderbaum.getEventInfo(e); const eventName = util.eventToString(e); const node = info.node || this.getFocusNode(); this._callHook("onKeyEvent", { event: e, node: node, info: info, eventName: eventName, }); }); util.onEvent(this.element, "focusin focusout", (e) => { const flag = e.type === "focusin"; const targetNode = Wunderbaum.getNode(e)!; this._callEvent("focus", { flag: flag, event: e }); if (flag && this.isRowNav() && !this.isEditingTitle()) { if ((opts.navigationModeOption as NavModeEnum) === NavModeEnum.row) { targetNode?.setActive(); } else { this.setCellNav(); } } if (!flag) { this._callMethod("edit._stopEditTitle", true, { event: e, forceClose: true, }); } }); } /** * Return a Wunderbaum instance, from element, id, index, or event. * * ```js * getTree(); // Get first Wunderbaum instance on page * getTree(1); // Get second Wunderbaum instance on page * getTree(event); // Get tree for this mouse- or keyboard event * getTree("foo"); // Get tree for this `tree.options.id` * getTree("#tree"); // Get tree for first matching element selector * ``` */ public static getTree( el?: Element | Event | number | string | WunderbaumNode ): Wunderbaum | null { if (el instanceof Wunderbaum) { return el; } else if (el instanceof WunderbaumNode) { return el.tree; } if (el === undefined) { el = 0; // get first tree } if (typeof el === "number") { el = document.querySelectorAll(".wunderbaum")[el]; // el was an integer: return nth element } else if (typeof el === "string") { // Search all trees for matching ID for (const treeElem of document.querySelectorAll(".wunderbaum")) { const tree = (<any>treeElem)._wb_tree; if (tree && tree.id === el) { return tree; } } // Search by selector el = document.querySelector(el)!; if (!el) { return null; } } else if ((<Event>el).target) { el = (<Event>el).target as Element; } util.assert(el instanceof Element, `Invalid el type: ${el}`); if (!(<HTMLElement>el).matches(".wunderbaum")) { el = (<HTMLElement>el).closest(".wunderbaum")!; } if (el && (<any>el)._wb_tree) { return (<any>el)._wb_tree; } return null; } /** * Return the icon-function -> icon-definition mapping. */ get iconMap(): IconMapType { const map = this.options.iconMap!; if (typeof map === "string") { return iconMaps[map]; } return map; } /** * Return a WunderbaumNode instance from element or event. */ public static getNode(el: Element | Event): WunderbaumNode | null { if (!el) { return null; } else if (el instanceof WunderbaumNode) { return el; } else if ((<Event>el).target !== undefined) { el = (<Event>el).target! as Element; // el was an Event } // `el` is a DOM element // let nodeElem = obj.closest("div.wb-row"); while (el) { if ((<any>el)._wb_node) { return (<any>el)._wb_node as WunderbaumNode; } el = (<Element>el).parentElement!; //.parentNode; } return null; } /** * Iterate all descendant nodes depth-first, pre-order using `for ... of ...` syntax. * More concise, but slightly slower than {@link Wunderbaum.visit}. * * Example: * ```js * for(const node of tree) { * ... * } * ``` */ *[Symbol.iterator](): IterableIterator<WunderbaumNode> { yield* this.root; } /** @internal */ protected _registerExtension(extension: WunderbaumExtension<any>): void { this.extensionList.push(extension); this.extensions[extension.id] = extension; // this.extensionMap.set(extension.id, extension); } /** Called on tree (re)init after markup is created, before loading. */ protected _initExtensions(): void { for (const ext of this.extensionList) { ext.init(); } } /** Add node to tree's bookkeeping data structures. */ _registerNode(node: WunderbaumNode): void { const key = node.key; util.assert(key != null, `Missing key: '${node}'.`); util.assert(!this.keyMap.has(key), `Duplicate key: '${key}': ${node}.`); this.keyMap.set(key, node); const rk = node.refKey; if (rk != null) { const rks = this.refKeyMap.get(rk); // Set of nodes with this refKey if (rks) { rks.add(node); } else { this.refKeyMap.set(rk, new Set([node])); } } } /** Remove node from tree's bookkeeping data structures. */ _unregisterNode(node: WunderbaumNode): void { // Remove refKey reference from map (if any) const rk = node.refKey; if (rk != null) { const rks = this.refKeyMap.get(rk); if (rks && rks.delete(node) && !rks.size) { // We just removed the last element this.refKeyMap.delete(rk); } } // Remove key reference from map this.keyMap.delete(node.key); // Mark as disposed (node.tree as any) = null; (node.parent as any) = null; // Remove HTML markup node.removeMarkup(); } /** Call all hook methods of all registered extensions.*/ protected _callHook( hook: keyof WunderbaumExtension<any>, data: any = {} ): any { let res; const d = util.extend( {}, { tree: this, options: this.options, result: undefined }, data ); for (const ext of this.extensionList) { res = (<any>ext[hook]).call(ext, d); if (res === false) { break; } if (d.result !== undefined) { res = d.result; } } return res; } /** * Call tree method or extension method if defined. * * Example: * ```js * tree._callMethod("edit.startEdit", "arg1", "arg2") * ``` */ _callMethod(name: string, ...args: any[]): any { const [p, n] = name.split("."); const obj = n ? this.extensions[p] : this; const func = (<any>obj)[n]; if (func) { return func.apply(obj, args); } else { this.logError(`Calling undefined method '${name}()'.`); } } /** * Call event handler if defined in tree or tree.EXTENSION options. * * Example: * ```js * tree._callEvent("edit.beforeEdit", {foo: 42}) * ``` */ _callEvent(type: string, extra?: any): any { const [p, n] = type.split("."); const opts = this.options as any; const func = n ? opts[p][n] : opts[p]; if (func) { return func.call( this, util.extend({ type: type, tree: this, util: this._util }, extra) ); // } else { // this.logError(`Triggering undefined event '${type}'.`) } } /** Return the node for given row index. */ protected _getNodeByRowIdx(idx: number): WunderbaumNode | null { // TODO: start searching from active node (reverse) let node: WunderbaumNode | null = null; this.visitRows((n) => { if (n._rowIdx === idx) { node = n; return false; } }); return <WunderbaumNode>node!; } /** Return the topmost visible node in the viewport. * @param complete If `false`, the node is considered visible if at least one * pixel is visible. */ getTopmostVpNode(complete = true) { const rowHeight = this.options.rowHeightPx!; const gracePx = 1; // ignore subpixel scrolling const scrollParent = this.element; // const headerHeight = this.headerElement.clientHeight; // May be 0 const scrollTop = scrollParent.scrollTop; // + headerHeight; let topIdx: number; if (complete) { topIdx = Math.ceil((scrollTop - gracePx) / rowHeight); } else { topIdx = Math.floor(scrollTop / rowHeight); } return this._getNodeByRowIdx(topIdx)!; } /** Return the lowest visible node in the viewport. */ getLowestVpNode(complete = true) { const rowHeight = this.options.rowHeightPx!; const scrollParent = this.element; const headerHeight = this.headerElement.clientHeight; // May be 0 const scrollTop = scrollParent.scrollTop; const clientHeight = scrollParent.clientHeight - headerHeight; let bottomIdx: number; if (complete) { bottomIdx = Math.floor((scrollTop + clientHeight) / rowHeight) - 1; } else { bottomIdx = Math.ceil((scrollTop + clientHeight) / rowHeight) - 1; } bottomIdx = Math.min(bottomIdx, this.count(true) - 1); return this._getNodeByRowIdx(bottomIdx)!; } /** Return following visible node in the viewport. */ protected _getNextNodeInView( node?: WunderbaumNode, options?: { ofs?: number; reverse?: boolean; cb?: (n: WunderbaumNode) => boolean; } ) { let ofs = options?.ofs || 1; const reverse = !!options?.reverse; this.visitRows( (n) => { node = n; if (options?.cb && options.cb(n)) { return false; } if (ofs-- <= 0) { return false; } }, { reverse: reverse, start: node || this.getActiveNode() } ); return node; } /** * Append (or insert) a list of toplevel nodes. * * @see {@link WunderbaumNode.addChildren} */ addChildren(nodeData: any, options?: AddChildrenOptions): WunderbaumNode { return this.root.addChildren(nodeData, options); } /** * Apply a modification (or navigation) operation on the **tree or active node**. */ applyCommand(cmd: ApplyCommandType, options?: ApplyCommandOptions): any; /** * Apply a modification (or navigation) operation on a **node**. * @see {@link WunderbaumNode.applyCommand} */ applyCommand( cmd: ApplyCommandType, node: WunderbaumNode, options?: ApplyCommandOptions ): any; /** * Apply a modification or navigation operation. * * Most of these commands simply map to a node or tree method. * This method is especially useful when implementing keyboard mapping, * context menus, or external buttons. * * Valid commands: * - 'moveUp', 'moveDown' * - 'indent', 'outdent' * - 'remove' * - 'edit', 'addChild', 'addSibling': (reqires ext-edit extension) * - 'cut', 'copy', 'paste': (use an internal singleton 'clipboard') * - 'down', 'first', 'last', 'left', 'parent', 'right', 'up': navigate * */ applyCommand( cmd: ApplyCommandType, nodeOrOpts?: WunderbaumNode | any, options?: ApplyCommandOptions ): any { let // clipboard, node, refNode; // options = $.extend( // { setActive: true, clipboard: CLIPBOARD }, // options_ // ); if (nodeOrOpts instanceof WunderbaumNode) { node = nodeOrOpts; } else { node = this.getActiveNode()!; util.assert(options === undefined, `Unexpected options: ${options}`); options = nodeOrOpts; } // clipboard = options.clipboard; switch (cmd) { // Sorting and indentation: case "moveUp": refNode = node.getPrevSibling(); if (refNode) { node.moveTo(refNode, "before"); node.setActive(); } break; case "moveDown": refNode = node.getNextSibling(); if (refNode) { node.moveTo(refNode, "after"); node.setActive(); } break; case "indent": refNode = node.getPrevSibling(); if (refNode) { node.moveTo(refNode, "appendChild"); refNode.setExpanded(); node.setActive(); } break; case "outdent": if (!node.isTopLevel()) { node.moveTo(node.getParent()!, "after"); node.setActive(); } break; // Remove: case "remove": refNode = node.getPrevSibling() || node.getParent(); node.remove(); if (refNode) { refNode.setActive(); } break; // Add, edit (requires ext-edit): case "addChild": this._callMethod("edit.createNode", "prependChild"); break; case "addSibling": this._callMethod("edit.createNode", "after"); break; case "rename": node.startEditTitle(); break; // Simple clipboard simulation: // case "cut": // clipboard = { mode: cmd, data: node }; // break; // case "copy": // clipboard = { // mode: cmd, // data: node.toDict(function(d, n) { // delete d.key; // }), // }; // break; // case "clear": // clipboard = null; // break; // case "paste": // if (clipboard.mode === "cut") { // // refNode = node.getPrevSibling(); // clipboard.data.moveTo(node, "child"); // clipboard.data.setActive(); // } else if (clipboard.mode === "copy") { // node.addChildren(clipboard.data).setActive(); // } // break; // Navigation commands: case "down": case "first": case "last": case "left": case "nextMatch": case "pageDown": case "pageUp": case "parent": case "prevMatch": case "right": case "up": return node.navigate(cmd); default: util.error(`Unhandled command: '${cmd}'`); } } /** Delete all nodes. */ clear() { this.root.removeChildren(); this.root.children = null; this.keyMap.clear(); this.refKeyMap.clear(); this.treeRowCount = 0; this._activeNode = null; this._focusNode = null; // this.types = {}; // this. columns =[]; // this._columnsById = {}; // Modification Status // this.changedSince = 0; // this.changes.clear(); // this.changedNodes.clear(); // // --- FILTER --- // public filterMode: FilterModeType = null; // // --- KEYNAV --- // public activeColIdx = 0; // public cellNavMode = false; // public lastQuicksearchTime = 0; // public lastQuicksearchTerm = ""; this.update(ChangeType.structure); } /** * Clear nodes and markup and detach events and observers. * * This method may be useful to free up resources before re-creating a tree * on an existing div, for example in unittest suites. * Note that this Wunderbaum instance becomes unusable afterwards. */ public destroy() { this.logInfo("destroy()..."); this.clear(); this.resizeObserver.disconnect(); this.element.innerHTML = ""; // Remove all event handlers this.element.outerHTML = this.element.outerHTML; // eslint-disable-line } /** * Return `tree.option.NAME` (also resolving if this is a callback). * * See also {@link WunderbaumNode.getOption|WunderbaumNode.getOption()} * to evaluate `node.NAME` setting and `tree.types[node.type].NAME`. * * @param name option name (use dot notation to access extension option, e.g. * `filter.mode`) */ getOption(name: string, defaultValue?: any): any { let ext; let opts = this.options as any; // Lookup `name` in options dict if (name.indexOf(".") >= 0) { [ext, name] = name.split("."); opts = opts[ext]; } let value = opts[name]; // A callback resolver always takes precedence if (typeof value === "function") { value = value({ type: "resolve", tree: this }); } // Use value from value options dict, fallback do default // console.info(name, value, opts) return value ?? defaultValue; } /** * Set tree option. * Use dot notation to set plugin option, e.g. "filter.mode". */ setOption(name: string, value: any): void { // this.log(`setOption(${name}, ${value})`); if (name.indexOf(".") >= 0) { const parts = name.split("."); const ext = this.extensions[parts[0]]; ext!.setPluginOption(parts[1], value); return; } (this.options as any)[name] = value; switch (name) { case "checkbox": this.update(ChangeType.any); break; case "enabled": this.setEnabled(!!value); break; case "fixedCol": this.element.classList.toggle("wb-fixed-col", !!value); break; } } /** Return true if the tree (or one of its nodes) has the input focus. */ hasFocus() { return this.element.contains(document.activeElement); } /** * Return true if the tree displays a header. Grids have a header unless the * `header` option is set to `false`. Plain trees have a header if the `header` * option is a string or `true`. */ hasHeader() { const header = this.options.header; return this.isGrid() ? header !== false : !!header; } /** Run code, but defer rendering of viewport until done. * * ```js * tree.runWithDeferredUpdate(() => { * return someFuncThatWouldUpdateManyNodes(); * }); * ``` */ runWithDeferredUpdate(func: () => any, hint = null): any { try { this.enableUpdate(false); const res = func(); util.assert( !(res instanceof Promise), `Promise return not allowed: ${res}` ); return res; } finally { this.enableUpdate(true); } } /** Recursively expand all expandable nodes (triggers lazy load if needed). */ async expandAll(flag: boolean = true, options?: ExpandAllOptions) { await this.root.expandAll(flag, options); } /** Recursively select all nodes. */ selectAll(flag: boolean = true) { return this.root.setSelected(flag, { propagateDown: true }); } /** Toggle select all nodes. */ toggleSelect() { this.selectAll(this.root._anySelectable()); } /** * Return an array of selected nodes. * @param stopOnParents only return the topmost selected node (useful with selectMode 'hier') */ getSelectedNodes(stopOnParents: boolean = false): WunderbaumNode[] { return this.root.getSelectedNodes(stopOnParents); } /* * Return an array of selected nodes. */ protected _selectRange(eventInfo: WbEventInfo): false | void { this.logDebug("_selectRange", eventInfo); util.error("Not yet implemented."); // const mode = this.options.selectMode!; // if (mode !== "multi") { // this.logDebug(`Range selection only available for selectMode 'multi'`); // return; // } // if (eventInfo.canonicalName === "Meta+click") { // eventInfo.node?.toggleSelected(); // return false; // don't // } else if (eventInfo.canonicalName === "Shift+click") { // let from = this.activeNode; // let to = eventInfo.node; // if (!from || !to || from === to) { // return; // } // this.runWithDeferredUpdate(() => { // this.visitRows( // (node) => { // node.setSelected(); // }, // { // includeHidden: true, // includeSelf: false, // start: from, // reverse: from!._rowIdx! > to!._rowIdx!, // } // ); // }); // return false; // } } /** Return the number of nodes in the data model. * @param visible if true, nodes that are hidden due to collapsed parents are ignored. */ count(visible = false): number { return visible ? this.treeRowCount : this.keyMap.size; } /** Return the number of *unique* nodes in the data model, i.e. unique `node.refKey`. */ countUnique(): number { return this.refKeyMap.size; } /** @internal sanity check. */ _check() { let i = 0; this.visit((n) => { i++; }); if (this.keyMap.size !== i) { this.logWarn(`_check failed: ${this.keyMap.size} !== ${i}`); } // util.assert(this.keyMap.size === i); } /** * Find all nodes that match condition. * * @param match title string to search for, or a * callback function that returns `true` if a node is matched. * @see {@link WunderbaumNode.findAll} */ findAll(match: string | RegExp | MatcherCallback) { return this.root.findAll(match); } /** * Find all nodes with a given _refKey_ (aka a list of clones). * * @param refKey a `node.refKey` value to search for. * @returns an array of matching nodes with at least two element or `[]` * if nothing found. * * @see {@link WunderbaumNode.getCloneList} */ findByRefKey(refKey: string): WunderbaumNode[] { const clones = this.refKeyMap.get(refKey); return clones ? Array.from(clones) : []; } /** * Find first node that matches condition. * * @param match title string to search for, or a * callback function that returns `true` if a node is matched. * @see {@link WunderbaumNode.findFirst} */ findFirst(match: string | RegExp | MatcherCallback) { return this.root.findFirst(match); } /** * Find first node that matches condition. * * @see {@link WunderbaumNode.findFirst} * */ findKey(key: string): WunderbaumNode | null { return this.keyMap.get(key) || null; } /** * Find the next visible node that starts with `match`, starting at `startNode` * and wrap-around at the end. * Used by quicksearch and keyboard navigation. */ findNextNode( match: string | MatcherCallback, startNode?: WunderbaumNode | null, reverse = false ): WunderbaumNode | null { //, visibleOnly) { let res: WunderbaumNode | null = null; const firstNode = this.getFirstChild()!; // Last visible node (calculation is expensive, so do only if we need it): const lastNode = reverse ? this.findRelatedNode(firstNode, "last")! : null; const matcher = typeof match === "string" ? makeNodeTitleStartMatcher(match) : match; startNode = startNode || (reverse ? lastNode : firstNode); function _checkNode(n: WunderbaumNode) { // console.log("_check " + n) if (matcher(n)) { res = n; } if (res || n === startNode) { return false; } } this.visitRows(_checkNode, { start: startNode, includeSelf: false, reverse: reverse, }); // Wrap around search if (!res && startNode !== firstNode) { this.visitRows(_checkNode, { start: reverse ? lastNode : firstNode, includeSelf: true, reverse: reverse, }); } return res; } /** * Find a node relative to another node. * * @param node * @param where 'down', 'first', 'last', 'left', 'parent', 'right', or 'up'. * (Alternatively the keyCode that would normally trigger this move, * e.g. `$.ui.keyCode.LEFT` = 'left'. * @param includeHidden Not yet implemented */ findRelatedNode( node: WunderbaumNode, where: NavigationType, includeHidden = false ) { const rowHeight = this.options.rowHeightPx!; let res = null; const pageSize = Math.floor( this.listContainerElement.clientHeight / rowHeight ); switch (where) { case "parent": if (node.parent && node.parent.parent) { res = node.parent; } break; case "first": // First visible node this.visit((n) => { if (n.isVisible()) { res = n; return false; } }); break; case "last": this.visit((n) => { // last visible node if (n.isVisible()) { res = n; } }); break; case "left": if (node.parent && node.parent.parent) { res = node.parent; } // if (node.expanded) { // node.setExpanded(false); // } else if (node.parent && node.parent.parent) { // res = node.parent; // } break; case "right": if (node.children && node.children.length) { res = node.children[0]; } // if (this.cellNavMode) { // throw new Error("Not implemented"); // } else { // if (!node.expanded && (node.children || node.lazy)) { // node.setExpanded(); // res = node; // } else if (node.children && node.children.length) { // res = node.children[0]; // } // } break; case "up": res = this._getNextNodeInView(node, { reverse: true }); break; case "down": res = this._getNextNodeInView(node); break; case "pageDown": { const bottomNode = this.getLowestVpNode(); // this.logDebug(`${where}(${node}) -> ${bottomNode}`); if (node._rowIdx! < bottomNode._rowIdx!) { res = bottomNode; } else { res = this._getNextNodeInView(node, { reverse: false, ofs: pageSize, }); } } break; case "pageUp": if (node._rowIdx === 0) { res = node; } else { const topNode = this.getTopmostVpNode(); // this.logDebug(`${where}(${node}) -> ${topNode}`); if (node._rowIdx! > topNode._rowIdx!) { res = topNode; } else { res = this._getNextNodeInView(node, { reverse: true, ofs: pageSize, }); } } break; case "prevMatch": // fallthrough case "nextMatch": if (!this.isFilterActive) { this.logWarn(`${where}: Filter is not active.`); break; } res = this.findNextNode( (n) => n.isMatched(), node, where === "prevMatch" ); res?.setActive(); break; default: this.logWarn("Unknown relation '" + where + "'."); } return res; } /** * Iterator version of {@link Wunderbaum.format}. */ *format_iter( name_cb?: NodeStringCallback, connectors?: string[] ): IterableIterator<string> { yield* this.root.format_iter(name_cb, connectors); } /** * Return multiline string representation of the node hierarchy. * Mostly useful for debugging. * * Example: * ```js * console.info(tree.format((n)=>n.title)); * ``` * logs * ``` * Playground * ├─ Books * | ├─ Art of War * | ╰─ Don Quixote * ├─ Music * ... * ``` * * @see {@link Wunderbaum.format_iter} and {@link WunderbaumNode.format}. */ format(name_cb?: NodeStringCallback, connectors?: string[]): string { return this.root.format(name_cb, connectors); } /** * Return the active cell (`span.wb-col`) of the currently active node or null. */ getActiveColElem() { if (this.activeNode && this.activeColIdx >= 0) { return this.activeNode.getColElem(this.activeColIdx); } return null; } /** * Return the currently active node or null (alias for `tree.activeNode`). * Alias for {@link Wunderbaum.activeNode}. * * @see {@link WunderbaumNode.setActive} * @see {@link WunderbaumNode.isActive} * @see {@link Wunderbaum.activeNode} * @see {@link Wunderbaum.focusNode} */ getActiveNode() { return this.activeNode; } /** * Return the first top level node if any (not the invisible root node). */ getFirstChild() { return this.root.getFirstChild(); } /** * Return the last top level node if any (not the invisible root node). */ getLastChild() { return this.root.getLastChild(); } /** * Return the node that currently has keyboard focus or null. * Alias for {@link Wunderbaum.focusNode}. * @see {@link WunderbaumNode.setFocus} * @see {@link WunderbaumNode.hasFocus} * @see {@link Wunderbaum.activeNode} * @see {@link Wunderbaum.focusNode} */ getFocusNode() { return this.focusNode; } /** Return a {node: WunderbaumNode, region: TYPE} object for a mouse event. * * @param {Event} event Mouse event, e.g. click, ... * @returns {object} Return a {node: WunderbaumNode, region: TYPE} object * TYPE: 'title' | 'prefix' | 'expander' | 'checkbox' | 'icon' | undefined */ static getEventInfo(event: Event): WbEventInfo { const target = <Element>event.target; const cl = target.classList; const parentCol = target.closest("span.wb-col") as HTMLSpanElement; const node = Wunderbaum.getNode(target); const tree = node ? node.tree : Wunderbaum.getTree(event); const res: WbEventInfo = { event: <MouseEvent>event, canonicalName: util.eventToString(event), tree: tree!, node: node, region: NodeRegion.unknown, colDef: undefined, colIdx: -1, colId: undefined, colElem: parentCol, }; if (cl.contains("wb-title")) { res.region = NodeRegion.title; } else if (cl.contains("wb-expander")) { res.region = node!.isExpandable() ? NodeRegion.expander : NodeRegion.prefix; } else if (cl.contains("wb-checkbox")) { res.region = NodeRegion.checkbox; } else if (cl.contains("wb-icon")) { //|| cl.contains("wb-custom-icon")) { res.region = NodeRegion.icon; } else if (cl.contains("wb-node")) { res.region = NodeRegion.title; } else if (parentCol) { res.region = NodeRegion.column; const idx = Array.prototype.indexOf.call( parentCol.parentNode!.children, parentCol ); res.colIdx = idx; } else if (cl.contains("wb-row")) { // Plain tree res.region = NodeRegion.title; } else { // Somewhere near the title if (event.type !== "mousemove" && !(event instanceof KeyboardEvent)) { tree?.logWarn("getEventInfo(): not found", event, res); } return res; } if (res.colIdx === -1) { res.colIdx = 0; } res.colDef = <any>tree?.columns[res.colIdx]; res.colDef != null ? (res.colId = (<any>res.colDef).id) : 0; // this.log("Event", event, res); return res; } /** * Return readable string representation for this instance. * @internal */ toString() { return `Wunderbaum<'${this.id}'>`; } /** Return true if any node title or grid cell is currently beeing edited. * * See also {@link Wunderbaum.isEditingTitle}. */ isEditing(): boolean { const focusElem = this.nodeListElement.querySelector( "input:focus,select:focus" ); return !!focusElem; } /** Return true if any node is currently in edit-title mode. * * See also {@link WunderbaumNode.isEditingTitle} and {@link Wunderbaum.isEditing}. */ isEditingTitle(): boolean { return this._callMethod("edit.isEditingTitle"); } /** * Return true if any node is currently beeing loaded, i.e. a Ajax request is pending. */ isLoading(): boolean { let res = false; this.root.visit((n) => { // also visit rootNode if (n._isLoading || n._requestId) { res = true; return false; } }, true); return res; } /** Write to `console.log` with tree name as prefix if opts.debugLevel >= 4. * @see {@link Wunderbaum.logDebug} */ log(...args: any[]) { if (this.options.debugLevel! >= 4) { console.log(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.debug` with tree name as prefix if opts.debugLevel >= 4. * and browser console level includes debug/verbose messages. * @see {@link Wunderbaum.log} */ logDebug(...args: any[]) { if (this.options.debugLevel! >= 4) { console.debug(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.error` with tree name as prefix. */ logError(...args: any[]) { if (this.options.debugLevel! >= 1) { console.error(this.toString(), ...args); // eslint-disable-line no-console } } /** Write to `console.info` with tree name as prefix if opts.debugLevel >= 3. */ logInfo(...args: any[]) { if (this.options.debugLevel! >= 3) { console.info(this.toString(), ...args); // eslint-disable-line no-console } } /** @internal */ logTime(label: string): string { if (this.options.debugLevel! >= 4) { console.time(this + ": " + label); // eslint-disable-line no-console } return label; } /** @internal */ logTimeEnd(label: string): void { if (this.options.debugLevel! >= 4) { console.timeEnd(this + ": " + label); // eslint-disable-line no-console } } /** Write to `console.warn` with tree name as prefix with if opts.debugLevel >= 2. */ logWarn(...args: any[]) { if (this.options.debugLevel! >= 2) { console.warn(this.toString(), ...args); // eslint-disable-line no-console } } /** Reset column widths to default. @since 0.10.0 */ resetColumns() { this.columns.forEach((col) => { delete col.customWidthPx; }); this.update(ChangeType.colStructure); } // /** Renumber nodes `_nat