wunderbaum
Version:
JavaScript tree/grid/treegrid control.
1,716 lines (1,576 loc) • 89.5 kB
text/typescript
/*!
* 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