wunderbaum
Version:
JavaScript tree/grid/treegrid control.
8,489 lines • 322 kB
JavaScript
/*!
* Wunderbaum - debounce.ts
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
/*
* debounce & throttle, taken from https://github.com/lodash/lodash v4.17.21
* MIT License: https://raw.githubusercontent.com/lodash/lodash/4.17.21-npm/LICENSE
* Modified for TypeScript type annotations.
*/
/* --- */
/** Detect free variable `global` from Node.js. */
const freeGlobal = typeof global === "object" &&
global !== null &&
global.Object === Object &&
global;
/** Detect free variable `globalThis` */
const freeGlobalThis = typeof globalThis === "object" &&
globalThis !== null &&
globalThis.Object == Object &&
globalThis;
/** Detect free variable `self`. */
const freeSelf = typeof self === "object" && self !== null && self.Object === Object && self;
/** Used as a reference to the global object. */
const root = freeGlobalThis || freeGlobal || freeSelf || Function("return this")();
/**
* Checks if `value` is the
* [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)
* of `Object`. (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`)
*
* @since 0.1.0
* @category Lang
* @param {*} value The value to check.
* @returns {boolean} Returns `true` if `value` is an object, else `false`.
* @example
*
* isObject({})
* // => true
*
* isObject([1, 2, 3])
* // => true
*
* isObject(Function)
* // => true
*
* isObject(null)
* // => false
*/
function isObject(value) {
const type = typeof value;
return value != null && (type === "object" || type === "function");
}
/**
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked, or until the next browser frame is drawn. The debounced function
* comes with a `cancel` method to cancel delayed `func` invocations and a
* `flush` method to immediately invoke them. Provide `options` to indicate
* whether `func` should be invoked on the leading and/or trailing edge of the
* `wait` timeout. The `func` is invoked with the last arguments provided to the
* debounced function. Subsequent calls to the debounced function return the
* result of the last `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the debounced function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* If `wait` is omitted in an environment with `requestAnimationFrame`, `func`
* invocation will be deferred until the next frame is drawn (typically about
* 16ms).
*
* See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
* for details over the differences between `debounce` and `throttle`.
*
* @since 0.1.0
* @category Function
* @param {Function} func The function to debounce.
* @param {number} [wait=0]
* The number of milliseconds to delay; if omitted, `requestAnimationFrame` is
* used (if available).
* @param [options={}] The options object.
* @returns {Function} Returns the new debounced function.
* @example
*
* // Avoid costly calculations while the window size is in flux.
* jQuery(window).on('resize', debounce(calculateLayout, 150))
*
* // Invoke `sendMail` when clicked, debouncing subsequent calls.
* jQuery(element).on('click', debounce(sendMail, 300, {
* 'leading': true,
* 'trailing': false
* }))
*
* // Ensure `batchLog` is invoked once after 1 second of debounced calls.
* const debounced = debounce(batchLog, 250, { 'maxWait': 1000 })
* const source = new EventSource('/stream')
* jQuery(source).on('message', debounced)
*
* // Cancel the trailing debounced invocation.
* jQuery(window).on('popstate', debounced.cancel)
*
* // Check for pending invocations.
* const status = debounced.pending() ? "Pending..." : "Ready"
*/
function debounce(func, wait = 0, options = {}) {
let lastArgs, lastThis, maxWait, result, timerId, lastCallTime;
let lastInvokeTime = 0;
let leading = false;
let maxing = false;
let trailing = true;
// Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
const useRAF = !wait && wait !== 0 && typeof root.requestAnimationFrame === "function";
if (typeof func !== "function") {
throw new TypeError("Expected a function");
}
wait = +wait || 0;
if (isObject(options)) {
leading = !!options.leading;
maxing = "maxWait" in options;
maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait;
trailing = "trailing" in options ? !!options.trailing : trailing;
}
function invokeFunc(time) {
const args = lastArgs;
const thisArg = lastThis;
lastArgs = lastThis = undefined;
lastInvokeTime = time;
result = func.apply(thisArg, args);
return result;
}
function startTimer(pendingFunc, wait) {
if (useRAF) {
root.cancelAnimationFrame(timerId);
return root.requestAnimationFrame(pendingFunc);
}
return setTimeout(pendingFunc, wait);
}
function cancelTimer(id) {
if (useRAF) {
return root.cancelAnimationFrame(id);
}
clearTimeout(id);
}
function leadingEdge(time) {
// Reset any `maxWait` timer.
lastInvokeTime = time;
// Start the timer for the trailing edge.
timerId = startTimer(timerExpired, wait);
// Invoke the leading edge.
return leading ? invokeFunc(time) : result;
}
function remainingWait(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
const timeWaiting = wait - timeSinceLastCall;
return maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
}
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime;
const timeSinceLastInvoke = time - lastInvokeTime;
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (lastCallTime === undefined ||
timeSinceLastCall >= wait ||
timeSinceLastCall < 0 ||
(maxing && timeSinceLastInvoke >= maxWait));
}
function timerExpired() {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// Restart the timer.
timerId = startTimer(timerExpired, remainingWait(time));
}
function trailingEdge(time) {
timerId = undefined;
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs) {
return invokeFunc(time);
}
lastArgs = lastThis = undefined;
return result;
}
function cancel() {
if (timerId !== undefined) {
cancelTimer(timerId);
}
lastInvokeTime = 0;
lastArgs = lastCallTime = lastThis = timerId = undefined;
}
function flush() {
return timerId === undefined ? result : trailingEdge(Date.now());
}
function pending() {
return timerId !== undefined;
}
function debounced(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(lastCallTime);
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = startTimer(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = startTimer(timerExpired, wait);
}
return result;
}
debounced.cancel = cancel;
debounced.flush = flush;
debounced.pending = pending;
return debounced;
}
/**
* Creates a throttled function that only invokes `func` at most once per
* every `wait` milliseconds (or once per browser frame). The throttled function
* comes with a `cancel` method to cancel delayed `func` invocations and a
* `flush` method to immediately invoke them. Provide `options` to indicate
* whether `func` should be invoked on the leading and/or trailing edge of the
* `wait` timeout. The `func` is invoked with the last arguments provided to the
* throttled function. Subsequent calls to the throttled function return the
* result of the last `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the throttled function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* If `wait` is omitted in an environment with `requestAnimationFrame`, `func`
* invocation will be deferred until the next frame is drawn (typically about
* 16ms).
*
* See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
* for details over the differences between `throttle` and `debounce`.
*
* @since 0.1.0
* @category Function
* @param {Function} func The function to throttle.
* @param {number} [wait=0]
* The number of milliseconds to throttle invocations to; if omitted,
* `requestAnimationFrame` is used (if available).
* @param [options={}] The options object.
* @returns {Function} Returns the new throttled function.
* @example
*
* // Avoid excessively updating the position while scrolling.
* jQuery(window).on('scroll', throttle(updatePosition, 100))
*
* // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes.
* const throttled = throttle(renewToken, 300000, { 'trailing': false })
* jQuery(element).on('click', throttled)
*
* // Cancel the trailing throttled invocation.
* jQuery(window).on('popstate', throttled.cancel)
*/
function throttle(func, wait = 0, options = {}) {
let leading = true;
let trailing = true;
if (typeof func !== "function") {
throw new TypeError("Expected a function");
}
if (isObject(options)) {
leading = "leading" in options ? !!options.leading : leading;
trailing = "trailing" in options ? !!options.trailing : trailing;
}
return debounce(func, wait, {
leading,
trailing,
maxWait: wait,
});
}
/*!
* Wunderbaum - util
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
/** @module util */
/** Readable names for `MouseEvent.button` */
const MOUSE_BUTTONS = {
0: "",
1: "left",
2: "middle",
3: "right",
4: "back",
5: "forward",
};
const MAX_INT = 9007199254740991;
const userInfo = _getUserInfo();
/**True if the client is using a macOS platform. */
const isMac = userInfo.isMac;
const REX_HTML = /[&<>"'/]/g; // Escape those characters
const REX_TOOLTIP = /[<>"'/]/g; // Don't escape `&` in tooltips
const ENTITY_MAP = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
"/": "/",
};
/** A generic error that can be thrown to indicate a validation error when
* handling the `apply` event for a node title or the `change` event for a
* grid cell.
*/
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
/**
* A ES6 Promise, that exposes the resolve()/reject() methods.
*
* TODO: See [Promise.withResolvers()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers#description)
* , a proposed standard, but not yet implemented in any browser.
*/
let Deferred$1 = class Deferred {
constructor() {
this.thens = [];
this.catches = [];
this.status = "";
}
resolve(value) {
if (this.status) {
throw new Error("already settled");
}
this.status = "resolved";
this.resolvedValue = value;
this.thens.forEach((t) => t(value));
this.thens = []; // Avoid memleaks.
}
reject(error) {
if (this.status) {
throw new Error("already settled");
}
this.status = "rejected";
this.rejectedError = error;
this.catches.forEach((c) => c(error));
this.catches = []; // Avoid memleaks.
}
then(cb) {
if (status === "resolved") {
cb(this.resolvedValue);
}
else {
this.thens.unshift(cb);
}
}
catch(cb) {
if (this.status === "rejected") {
cb(this.rejectedError);
}
else {
this.catches.unshift(cb);
}
}
promise() {
return {
then: this.then,
catch: this.catch,
};
}
};
/**Throw an `Error` if `cond` is falsey. */
function assert(cond, msg) {
if (!cond) {
msg = msg || "Assertion failed.";
throw new Error(msg);
}
}
function _getUserInfo() {
const nav = navigator;
// const ua = nav.userAgentData;
const res = {
isMac: /Mac/.test(nav.platform),
};
return res;
}
/** Run `callback` when document was loaded. */
function documentReady(callback) {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback);
}
else {
callback();
}
}
/** Resolve when document was loaded. */
function documentReadyPromise() {
return new Promise((resolve) => {
documentReady(resolve);
});
}
/**
* Iterate over Object properties or array elements.
*
* @param obj `Object`, `Array` or null
* @param callback called for every item.
* `this` also contains the item.
* Return `false` to stop the iteration.
*/
function each(obj, callback) {
if (obj == null) {
// accept `null` or `undefined`
return obj;
}
const length = obj.length;
let i = 0;
if (typeof length === "number") {
for (; i < length; i++) {
if (callback.call(obj[i], i, obj[i]) === false) {
break;
}
}
}
else {
for (const k in obj) {
if (callback.call(obj[i], k, obj[k]) === false) {
break;
}
}
}
return obj;
}
/** Shortcut for `throw new Error(msg)`.*/
function error(msg) {
throw new Error(msg);
}
/** Convert `<`, `>`, `&`, `"`, `'`, and `/` to the equivalent entities. */
function escapeHtml(s) {
return ("" + s).replace(REX_HTML, function (s) {
return ENTITY_MAP[s];
});
}
// export function escapeRegExp(s: string) {
// return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
// }
/**Convert a regular expression string by escaping special characters (e.g. `"$"` -> `"\$"`) */
function escapeRegex(s) {
return ("" + s).replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1");
}
/** Convert `<`, `>`, `"`, `'`, and `/` (but not `&`) to the equivalent entities. */
function escapeTooltip(s) {
return ("" + s).replace(REX_TOOLTIP, function (s) {
return ENTITY_MAP[s];
});
}
/** TODO */
function extractHtmlText(s) {
if (s.indexOf(">") >= 0) {
error("Not implemented");
// return $("<div/>").html(s).text();
}
return s;
}
/**
* Read the value from an HTML input element.
*
* If a `<span class="wb-col">` is passed, the first child input is used.
* Depending on the target element type, `value` is interpreted accordingly.
* For example for a checkbox, a value of true, false, or null is returned if
* the element is checked, unchecked, or indeterminate.
* For datetime input control a numerical value is assumed, etc.
*
* Common use case: store the new user input in a `change` event handler:
*
* ```ts
* change: (e) => {
* const tree = e.tree;
* const node = e.node;
* // Read the value from the input control that triggered the change event:
* let value = tree.getValueFromElem(e.element);
* // and store it to the node model (assuming the column id matches the property name)
* node.data[e.info.colId] = value;
* },
* ```
* @param elem `<input>` or `<select>` element. Also a parent `span.wb-col` is accepted.
* @param coerce pass true to convert date/time inputs to `Date`.
* @returns the value
*/
function getValueFromElem(elem, coerce = false) {
const tag = elem.tagName;
let value = null;
if (tag === "SPAN" && elem.classList.contains("wb-col")) {
const span = elem;
const embeddedInput = span.querySelector("input,select");
if (embeddedInput) {
return getValueFromElem(embeddedInput, coerce);
}
span.innerText = "" + value;
}
else if (tag === "INPUT") {
const input = elem;
const type = input.type;
switch (type) {
case "button":
case "reset":
case "submit":
case "image":
break;
case "checkbox":
value = input.indeterminate ? null : input.checked;
break;
case "date":
case "datetime":
case "datetime-local":
case "month":
case "time":
case "week":
value = coerce ? input.valueAsDate : input.value;
break;
case "number":
case "range":
value = input.valueAsNumber;
break;
case "radio":
{
const name = input.name;
const checked = input.parentElement.querySelector(`input[name="${name}"]:checked`);
value = checked ? checked.value : undefined;
}
break;
case "text":
default:
value = input.value;
}
}
else if (tag === "SELECT") {
const select = elem;
value = select.value;
}
return value;
}
/**
* Set the value of an HTML input element.
*
* If a `<span class="wb-col">` is passed, the first child input is used.
* Depending on the target element type, `value` is interpreted accordingly.
* For example a checkbox is set to checked, unchecked, or indeterminate if the
* value is truethy, falsy, or `null`.
* For datetime input control a numerical value is assumed, etc.
*
* Common use case: update embedded input controls in a `render` event handler:
*
* ```ts
* render: (e) => {
* // e.node.log(e.type, e, e.node.data);
*
* for (const col of Object.values(e.renderColInfosById)) {
* switch (col.id) {
* default:
* // Assumption: we named column.id === node.data.NAME
* util.setValueToElem(col.elem, e.node.data[col.id]);
* break;
* }
* }
* },
* ```
*
* @param elem `<input>` or `<select>` element Also a parent `span.wb-col` is accepted.
* @param value a value that matches the target element.
*/
function setValueToElem(elem, value) {
const tag = elem.tagName;
if (tag === "SPAN" && elem.classList.contains("wb-col")) {
const span = elem;
const embeddedInput = span.querySelector("input,select");
if (embeddedInput) {
return setValueToElem(embeddedInput, value);
}
// No embedded input: simply write as escaped html
span.innerText = "" + value;
}
else if (tag === "INPUT") {
const input = elem;
const type = input.type;
switch (type) {
case "checkbox":
// An explicit `null` value is interpreted as 'indeterminate'.
// `undefined` is interpreted as 'unchecked'
input.indeterminate = value === null;
input.checked = !!value;
break;
case "date":
case "month":
case "time":
case "week":
case "datetime":
case "datetime-local":
input.valueAsDate = new Date(value);
break;
case "number":
case "range":
if (value == null) {
input.value = value;
}
else {
input.valueAsNumber = value;
}
break;
case "radio":
error(`Not yet implemented: ${type}`);
// const name = input.name;
// const checked = input.parentElement!.querySelector(
// `input[name="${name}"]:checked`
// );
// value = checked ? (<HTMLInputElement>checked).value : undefined;
break;
case "button":
case "reset":
case "submit":
case "image":
break;
case "text":
default:
input.value = value !== null && value !== void 0 ? value : "";
}
}
else if (tag === "SELECT") {
const select = elem;
if (value == null) {
select.selectedIndex = -1;
}
else {
select.value = value;
}
}
}
/** Show/hide element by setting the `display` style to 'none'. */
function setElemDisplay(elem, flag) {
const style = elemFromSelector(elem).style;
if (flag) {
if (style.display === "none") {
style.display = "";
}
}
else if (style.display === "") {
style.display = "none";
}
}
/** Create and return an unconnected `HTMLElement` from a HTML string. */
function elemFromHtml(html) {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
const _IGNORE_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);
/** Return a HtmlElement from selector or cast an existing element. */
function elemFromSelector(obj) {
if (!obj) {
return null; //(null as unknown) as HTMLElement;
}
if (typeof obj === "string") {
return document.querySelector(obj);
}
return obj;
}
/**
* Return a canonical descriptive string for a keyboard or mouse event.
*
* The result also contains a prefix for modifiers if any, for example
* `"x"`, `"F2"`, `"Control+Home"`, or `"Shift+clickright"`.
* This is especially useful in `switch` statements, to make sure that modifier
* keys are considered and handled correctly:
* ```ts
* const eventName = util.eventToString(e);
* switch (eventName) {
* case "+":
* case "Add":
* ...
* break;
* case "Enter":
* case "End":
* case "Control+End":
* case "Meta+ArrowDown":
* case "PageDown":
* ...
* break;
* }
* ```
*/
function eventToString(event) {
const key = event.key;
const et = event.type;
const s = [];
if (event.altKey) {
s.push("Alt");
}
if (event.ctrlKey) {
s.push("Control");
}
if (event.metaKey) {
s.push("Meta");
}
if (event.shiftKey) {
s.push("Shift");
}
if (et === "click" || et === "dblclick") {
s.push(MOUSE_BUTTONS[event.button] + et);
}
else if (et === "wheel") {
s.push(et);
// } else if (!IGNORE_KEYCODES[key]) {
// s.push(
// SPECIAL_KEYCODES[key] ||
// String.fromCharCode(key).toLowerCase()
// );
}
else if (!_IGNORE_KEYS.has(key)) {
s.push(key);
}
return s.join("+");
}
/**
* Copy allproperties from one or more source objects to a target object.
*
* @returns the modified target object.
*/
// TODO: use Object.assign()? --> https://stackoverflow.com/a/42740894
// TODO: support deep merge --> https://stackoverflow.com/a/42740894
function extend(...args) {
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg == null) {
continue;
}
for (const key in arg) {
if (Object.prototype.hasOwnProperty.call(arg, key)) {
args[0][key] = arg[key];
}
}
}
return args[0];
}
/** Return true if `obj` is of type `array`. */
function isArray(obj) {
return Array.isArray(obj);
}
/** Return true if `obj` is of type `Object` and has no properties. */
function isEmptyObject(obj) {
return Object.keys(obj).length === 0 && obj.constructor === Object;
}
/** Return true if `obj` is of type `function`. */
function isFunction(obj) {
return typeof obj === "function";
}
/** Return true if `obj` is of type `Object`. */
function isPlainObject(obj) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
/** A dummy function that does nothing ('no operation'). */
function noop(...args) { }
function onEvent(rootTarget, eventNames, selectorOrHandler, handlerOrNone) {
let selector, handler;
rootTarget = elemFromSelector(rootTarget);
// rootTarget = eventTargetFromSelector<EventTarget>(rootTarget)!;
if (handlerOrNone) {
selector = selectorOrHandler;
handler = handlerOrNone;
}
else {
selector = "";
handler = selectorOrHandler;
}
eventNames.split(" ").forEach((evn) => {
rootTarget.addEventListener(evn, function (e) {
if (!selector) {
return handler(e); // no event delegation
}
else if (e.target) {
let elem = e.target;
if (elem.matches(selector)) {
return handler(e);
}
elem = elem.closest(selector);
if (elem) {
return handler(e);
}
}
});
});
}
/** Return a wrapped handler method, that provides `this._super` and `this._superApply`.
*
* ```ts
// Implement `opts.createNode` event to add the 'draggable' attribute
overrideMethod(ctx.options, "createNode", (event, data) => {
// Default processing if any
this._super.apply(this, event, data);
// Add 'draggable' attribute
data.node.span.draggable = true;
});
```
*/
function overrideMethod(instance, methodName, handler, ctx) {
let prevSuper, prevSuperApply;
const self = ctx || instance;
const prevFunc = instance[methodName];
const _super = (...args) => {
return prevFunc.apply(self, args);
};
const _superApply = (argsArray) => {
return prevFunc.apply(self, argsArray);
};
const wrapper = (...args) => {
try {
prevSuper = self._super;
prevSuperApply = self._superApply;
self._super = _super;
self._superApply = _superApply;
return handler.apply(self, args);
}
finally {
self._super = prevSuper;
self._superApply = prevSuperApply;
}
};
instance[methodName] = wrapper;
}
/** Run function after ms milliseconds and return a promise that resolves when done. */
function setTimeoutPromise(callback, ms) {
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
resolve(callback.apply(this));
}
catch (err) {
reject(err);
}
}, ms);
});
}
/**
* Wait `ms` microseconds.
*
* Example:
* ```js
* await sleep(1000);
* ```
* @param ms duration
* @returns
*/
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Set or rotate checkbox status with support for tri-state.
*
* An initial 'indeterminate' state becomes 'checked' on the first call.
*
* If the input element has the class 'wb-tristate' assigned, the sequence is:<br>
* 'indeterminate' -> 'checked' -> 'unchecked' -> 'indeterminate' -> ...<br>
* Otherwise we toggle like <br>
* 'checked' -> 'unchecked' -> 'checked' -> ...
*/
function toggleCheckbox(element, value, tristate) {
const input = elemFromSelector(element);
assert(input.type === "checkbox", `Expected a checkbox: ${input.type}`);
tristate !== null && tristate !== void 0 ? tristate : (tristate = input.classList.contains("wb-tristate") || input.indeterminate);
if (value === undefined) {
const curValue = input.indeterminate ? null : input.checked;
switch (curValue) {
case true:
value = false;
break;
case false:
value = tristate ? null : true;
break;
case null:
value = true;
break;
}
}
input.indeterminate = value == null;
input.checked = !!value;
}
/**
* Return `opts.NAME` if opts is valid and
*
* @param opts dict, object, or null
* @param name option name (use dot notation to access extension option, e.g. `filter.mode`)
* @param defaultValue returned when `opts` is not an object, or does not have a NAME property
*/
function getOption(opts, name, defaultValue = undefined) {
let ext;
// Lookup `name` in options dict
if (opts && name.indexOf(".") >= 0) {
[ext, name] = name.split(".");
opts = opts[ext];
}
const value = opts ? opts[name] : null;
// Use value from value options dict, fallback do default
return value !== null && value !== void 0 ? value : defaultValue;
}
/** Return the next value from a list of values (rotating). @since 0.11 */
function rotate(value, values) {
const idx = values.indexOf(value);
return values[(idx + 1) % values.length];
}
/** Convert an Array or space-separated string to a Set. */
function toSet(val) {
if (val instanceof Set) {
return val;
}
if (typeof val === "string") {
const set = new Set();
for (const c of val.split(" ")) {
set.add(c.trim());
}
return set;
}
if (Array.isArray(val)) {
return new Set(val);
}
throw new Error("Cannot convert to Set<string>: " + val);
}
/** Convert a pixel string to number.
* We accept a number or a string like '123px'. If undefined, the first default
* value that is a number or a string ending with 'px' is returned.
*
* Example:
* ```js
* let x = undefined;
* let y = "123px";
* const width = util.toPixel(x, y, 100); // returns 123
* ```
*/
function toPixel(...defaults) {
for (const d of defaults) {
if (typeof d === "number") {
return d;
}
if (typeof d === "string" && d.endsWith("px")) {
return parseInt(d, 10);
}
assert(d == null, `Expected a number or string like '123px': ${d}`);
}
throw new Error(`Expected a string like '123px': ${defaults}`);
}
/** Return the the boolean value of the first non-null element.
* Example:
* ```js
* const opts = { flag: true };
* const value = util.toBool(opts.foo, opts.flag, false); // returns true
* ```
*/
function toBool(...boolDefaults) {
for (const d of boolDefaults) {
if (d != null) {
return !!d;
}
}
throw new Error("No default boolean value provided");
}
/**
* Return `val` unless `val` is a number in which case we convert to boolean.
* This is useful when a boolean value is stored as a 0/1 (e.g. in JSON) and
* we still want to maintain string values. null and undefined are returned as
* is. E.g. `checkbox` may be boolean or 'radio'.
*/
function intToBool(val) {
return typeof val === "number" ? !!val : val;
}
// /** Check if a string is contained in an Array or Set. */
// export function isAnyOf(s: string, items: Array<string>|Set<string>): boolean {
// return Array.prototype.includes.call(items, s)
// }
// /** Check if an Array or Set has at least one matching entry. */
// export function hasAnyOf(container: Array<string>|Set<string>, items: Array<string>): boolean {
// if (Array.isArray(container)) {
// return container.some(v => )
// }
// return container.some(v => {})
// // const container = toSet(items);
// // const itemSet = toSet(items);
// // Array.prototype.includes
// // throw new Error("Cannot convert to Set<string>: " + val);
// }
/** Return a canonical string representation for an object's type (e.g. 'array', 'number', ...). */
function type(obj) {
return Object.prototype.toString
.call(obj)
.replace(/^\[object (.+)\]$/, "$1")
.toLowerCase();
}
/**
* Return a function that can be called instead of `callback`, but guarantees
* a limited execution rate.
* The execution rate is calculated based on the runtime duration of the
* previous call.
* Example:
* ```js
* throttledFoo = util.adaptiveThrottle(foo.bind(this), {});
* throttledFoo();
* throttledFoo();
* ```
*/
function adaptiveThrottle(callback, options) {
const opts = Object.assign({
minDelay: 16,
defaultDelay: 200,
maxDelay: 5000,
delayFactor: 2.0,
}, options);
const minDelay = Math.max(16, +opts.minDelay);
const maxDelay = +opts.maxDelay;
let waiting = 0; // Initially, we're not waiting
let pendingArgs = null;
let pendingTimer = null;
const throttledFn = (...args) => {
if (waiting) {
pendingArgs = args;
// console.log(`adaptiveThrottle() queing request #${waiting}...`, args);
waiting += 1;
}
else {
// Prevent invocations while running or blocking
waiting = 1;
const useArgs = args; // pendingArgs || args;
pendingArgs = null;
// console.log(`adaptiveThrottle() execute...`, useArgs);
const start = Date.now();
try {
callback.apply(this, useArgs);
}
catch (error) {
console.error(error); // eslint-disable-line no-console
}
const elap = Date.now() - start;
const curDelay = Math.min(Math.max(minDelay, elap * opts.delayFactor), maxDelay);
const useDelay = Math.max(minDelay, curDelay - elap);
// console.log(
// `adaptiveThrottle() calling worker took ${elap}ms. delay = ${curDelay}ms, using ${useDelay}ms`,
// pendingArgs
// );
pendingTimer = setTimeout(() => {
// Unblock, and trigger pending requests if any
// const skipped = waiting - 1;
pendingTimer = null;
waiting = 0; // And allow future invocations
if (pendingArgs != null) {
// There was another request while running or waiting
// console.log(
// `adaptiveThrottle() re-trigger (missed ${skipped})...`,
// pendingArgs
// );
throttledFn.apply(this, pendingArgs);
}
}, useDelay);
}
};
throttledFn.cancel = () => {
if (pendingTimer) {
clearTimeout(pendingTimer);
pendingTimer = null;
}
pendingArgs = null;
waiting = 0;
};
throttledFn.pending = () => {
return !!pendingTimer;
};
throttledFn.flush = () => {
throw new Error("Not implemented");
};
return throttledFn;
}
var util = /*#__PURE__*/Object.freeze({
__proto__: null,
Deferred: Deferred$1,
MAX_INT: MAX_INT,
MOUSE_BUTTONS: MOUSE_BUTTONS,
ValidationError: ValidationError,
adaptiveThrottle: adaptiveThrottle,
assert: assert,
debounce: debounce,
documentReady: documentReady,
documentReadyPromise: documentReadyPromise,
each: each,
elemFromHtml: elemFromHtml,
elemFromSelector: elemFromSelector,
error: error,
escapeHtml: escapeHtml,
escapeRegex: escapeRegex,
escapeTooltip: escapeTooltip,
eventToString: eventToString,
extend: extend,
extractHtmlText: extractHtmlText,
getOption: getOption,
getValueFromElem: getValueFromElem,
intToBool: intToBool,
isArray: isArray,
isEmptyObject: isEmptyObject,
isFunction: isFunction,
isMac: isMac,
isPlainObject: isPlainObject,
noop: noop,
onEvent: onEvent,
overrideMethod: overrideMethod,
rotate: rotate,
setElemDisplay: setElemDisplay,
setTimeoutPromise: setTimeoutPromise,
setValueToElem: setValueToElem,
sleep: sleep,
throttle: throttle,
toBool: toBool,
toPixel: toPixel,
toSet: toSet,
toggleCheckbox: toggleCheckbox,
type: type
});
/*!
* Wunderbaum - types
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
/**
* Possible values for {@link WunderbaumNode.update} and {@link Wunderbaum.update}.
*/
var ChangeType;
(function (ChangeType) {
/** Re-render the whole viewport, headers, and all rows. */
ChangeType["any"] = "any";
/** A node's title, icon, columns, or status have changed. Update the existing row markup. */
ChangeType["data"] = "data";
/** The `tree.columns` definition has changed beyond simple width adjustments. */
ChangeType["colStructure"] = "colStructure";
/** The viewport/window was resized. Adjust layout attributes for all elements. */
ChangeType["resize"] = "resize";
/** A node's definition has changed beyond status and data. Re-render the whole row's markup. */
ChangeType["row"] = "row";
/** Nodes have been added, removed, etc. Update markup. */
ChangeType["structure"] = "structure";
/** A node's status has changed. Update current row's classes, to reflect active, selected, ... */
ChangeType["status"] = "status";
/** Vertical scroll event. Update the 'top' property of all rows. */
ChangeType["scroll"] = "scroll";
})(ChangeType || (ChangeType = {}));
/** @internal */
var RenderFlag;
(function (RenderFlag) {
RenderFlag["clearMarkup"] = "clearMarkup";
RenderFlag["header"] = "header";
RenderFlag["redraw"] = "redraw";
RenderFlag["scroll"] = "scroll";
})(RenderFlag || (RenderFlag = {}));
/** Possible values for {@link WunderbaumNode.setStatus}. */
var NodeStatusType;
(function (NodeStatusType) {
NodeStatusType["ok"] = "ok";
NodeStatusType["loading"] = "loading";
NodeStatusType["error"] = "error";
NodeStatusType["noData"] = "noData";
NodeStatusType["paging"] = "paging";
})(NodeStatusType || (NodeStatusType = {}));
/** Define the subregion of a node, where an event occurred. */
var NodeRegion;
(function (NodeRegion) {
NodeRegion["unknown"] = "";
NodeRegion["checkbox"] = "checkbox";
NodeRegion["column"] = "column";
NodeRegion["expander"] = "expander";
NodeRegion["icon"] = "icon";
NodeRegion["prefix"] = "prefix";
NodeRegion["title"] = "title";
})(NodeRegion || (NodeRegion = {}));
/** Initial navigation mode and possible transition. */
var NavModeEnum;
(function (NavModeEnum) {
/** Start with row mode, but allow cell-nav mode */
NavModeEnum["startRow"] = "startRow";
/** Cell-nav mode only */
NavModeEnum["cell"] = "cell";
/** Start in cell-nav mode, but allow row mode */
NavModeEnum["startCell"] = "startCell";
/** Row mode only */
NavModeEnum["row"] = "row";
})(NavModeEnum || (NavModeEnum = {}));
/*!
* Wunderbaum - wb_extension_base
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
class WunderbaumExtension {
constructor(tree, id, defaults) {
this.enabled = true;
this.tree = tree;
this.id = id;
this.treeOpts = tree.options;
const opts = tree.options;
if (this.treeOpts[id] === undefined) {
opts[id] = this.extensionOpts = extend({}, defaults);
}
else {
// TODO: do we break existing object instance references here?
this.extensionOpts = extend({}, defaults, opts[id]);
opts[id] = this.extensionOpts;
}
this.enabled = this.getPluginOption("enabled", true);
}
/** Called on tree (re)init after all extensions are added, but before loading.*/
init() {
this.tree.element.classList.add("wb-ext-" + this.id);
}
// protected callEvent(type: string, extra?: any): any {
// let func = this.extensionOpts[type];
// if (func) {
// return func.call(
// this.tree,
// util.extend(
// {
// event: this.id + "." + type,
// },
// extra
// )
// );
// }
// }
getPluginOption(name, defaultValue) {
var _a;
return (_a = this.extensionOpts[name]) !== null && _a !== void 0 ? _a : defaultValue;
}
setPluginOption(name, value) {
this.extensionOpts[name] = value;
}
setEnabled(flag = true) {
return this.setPluginOption("enabled", !!flag);
// this.enabled = !!flag;
}
onKeyEvent(data) {
return;
}
onRender(data) {
return;
}
}
/*!
* Wunderbaum - ext-filter
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
const START_MARKER = "\uFFF7";
const END_MARKER = "\uFFF8";
const RE_START_MARKER = new RegExp(escapeRegex(START_MARKER), "g");
const RE_END_MARTKER = new RegExp(escapeRegex(END_MARKER), "g");
class FilterExtension extends WunderbaumExtension {
constructor(tree) {
super(tree, "filter", {
autoApply: true, // Re-apply last filter if lazy data is loaded
autoExpand: false, // Expand all branches that contain matches while filtered
matchBranch: false, // Whether to implicitly match all children of matched nodes
connect: null, // Element or selector of an input control for filter query strings
fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar'
hideExpanders: false, // Hide expanders if all child nodes are hidden by filter
highlight: true, // Highlight matches by wrapping inside <mark> tags
leavesOnly: false, // Match end nodes only
mode: "dim", // Grayout unmatched nodes (pass "hide" to remove unmatched node instead)
noData: true, // Display a 'no data' status node if result is empty
});
this.queryInput = null;
this.prevButton = null;
this.nextButton = null;
this.modeButton = null;
this.matchInfoElem = null;
this.lastFilterArgs = null;
}
init() {
super.init();
const connect = this.getPluginOption("connect");
if (connect) {
this._connectControls();
}
}
setPluginOption(name, value) {
super.setPluginOption(name, value);
switch (name) {
case "mode":
this.tree.filterMode =
value === "hide" ? "hide" : value === "mark" ? "mark" : "dim";
this.tree.updateFilter();
break;
}
}
_updatedConnectedControls() {
var _a;
const filterActive = this.tree.filterMode !== null;
const activeNode = this.tree.getActiveNode();
const matchCount = filterActive ? this.countMatches() : 0;
const strings = this.treeOpts.strings;
let matchIdx = "?";
if (this.matchInfoElem) {
if (filterActive) {
let info;
if (matchCount === 0) {
info = strings.noMatch;
}
else if (activeNode && activeNode.match >= 1) {
matchIdx = (_a = activeNode.match) !== null && _a !== void 0 ? _a : "?";
info = strings.matchIndex;
}
else {
info = strings.queryResult;
}
info = info
.replace("${count}", this.tree.count().toLocaleString())
.replace("${match}", "" + matchIdx)
.replace("${matches}", matchCount.toLocaleString());
this.matchInfoElem.textContent = info;
}
else {
this.matchInfoElem.textContent = "";
}
}
if (this.nextButton instanceof HTMLButtonElement) {
this.nextButton.disabled = !matchCount;
}
if (this.prevButton instanceof HTMLButtonElement) {
this.prevButton.disabled = !matchCount;
}
if (this.modeButton) {
this.modeButton.disabled = !filterActive;
this.modeButton.classList.toggle("wb-filter-hide", this.tree.filterMode === "hide");
}
}
_connectControls() {
const tree = this.tree;
const connect = this.getPluginOption("connect");
if (!connect) {
return;
}
this.queryInput = elemFromSelector(connect.inputElem);
if (!this.queryInput) {
throw new Error(`Invalid 'filter.connect' option: ${connect.inputElem}.`);
}
this.prevButton = elemFromSelector(connect.prevButton);
this.nextButton = elemFromSelector(connect.nextButton);
this.modeButton = elemFromSelector(connect.modeButton);
this.matchInfoElem = elemFromSelector(connect.matchInfoElem);
if (this.prevButton) {
onEvent(this.prevButton, "click", () => {
tree.findRelatedNode(tree.getActiveNode() || tree.getFirstChild(), "prevMatch");
this._updatedConnectedControls();
});
}
if (this.nextButton) {
onEvent(this.nextButton, "click", () => {
tree.findRelatedNode(tree.getActiveNode() || tree.getFirstChild(), "nextMatch");
this._updatedConnectedControls();
});
}
if (this.modeButton) {
onEvent(this.modeButton, "click", (e) => {
if (!this.tree.filterMode) {
return;
}
this.setPluginOption("mode", tree.filterMode === "dim" ? "hide" : "dim");
});
}
onEvent(this.queryInput, "input", debounce((e) => {
this.filterNodes(this.queryInput.value.trim(), {});
}, 700));
this._updatedConnectedControls();
}
_applyFilterNoUpdate(filter, _opts) {
return this.tree.runWithDeferredUpdate(() => {
return this._applyFilterImpl(filter, _opts);
});
}
_applyFilterImpl(filter, _opts) {
var _a;
let //temp,
count = 0;
const start = Date.now();
const tree = this.tree;
const treeOpts = tree.options;
const prevAutoCollapse = treeOpts.autoCollapse;
// Use default options from `tree.options.filter`, but allow to override them
const opts = extend({}, treeOpts.filter, _opts);
const hideMode = opts.mode === "hide";
const matchBranch = !!opts.matchBranch;
const leavesOnly = !!opts.leavesOnly && !matchBranch;
let filterRegExp;
let highlightRegExp;
// Default to 'match title substring (case insensitive)'
if (typeof filter === "string" || filter instanceof RegExp) {
if (filter === "") {
tree.logInfo("Passing an empty string as a filter is handled as clearFilter().");
this.clearFilter();
return 0;
}
if (opts.fuzzy) {
assert(typeof filter === "string", "fuzzy filter must be a string");
// See https://codereview.stackexchange.com/questions/23899/faster-javascript-fuzzy-string-matching-function/23905#23905
// and http://www.quora.com/How-is-the-fuzzy-search-algorithm-in-Sublime-Text-designed
// and http://www.dustindiaz.com/autocomplete-fuzzy-matching
const matchReString = filter
.split("")
// Escaping the `filter` will not work because,
// it gets further split into individual characters. So,
// escape each character after splitting
.map(escapeRegex)
.reduce(function (a, b) {
// create capture groups for parts that comes before
// the character
return a + "([^" + b + "]*)" + b;
}, "");
filterRegExp = new RegExp(matchReString, "i");
// highlightRegExp = new RegExp(escapeRegex(filter), "gi");
}
else if (filter instanceof RegExp) {
filterRegExp = filter;
highlightRegExp = filter;
}
else {
const matchReString = escapeRegex(filter); // make sure a '.' is treated literally
filterRegExp = new RegExp(matchReString, "i");
highlightRegExp = new RegExp(matchReString, "gi");
}
tree.logDebug(`Filtering nodes by '${filterRegExp}'`);
// const re = new RegExp(match, "i");
// const reHighlight = new RegExp(escapeRegex(filter), "gi");
filter = (node) => {
if (!node.title) {
return false;
}
// let text = escapeTitles ? node.title : extractHtmlText(node.title);
const text = node.title;
// `.match` instead of `.test` to get the capture groups
// const res = text.match(filterRegExp);
const res = filterRegExp.exec(text);
if (res && opts.highlight) {
let highlightString;
if (opts.fuzzy) {
highlightString = _markFuzzyMatchedChars(text, res, true);
}
else {
// #740: we must not apply the marks to escaped entity names, e.g. `"`
// Use some exotic characters to mark matches:
highlightString = text.replace(highlightRegExp, function (s) {
return START_MARKER + s + END_MARKER;
});
}
// now we can escape the title...
node.titleWithHighlight = escapeHtml(highlightString)
// ... and finally insert the desired `<mark>` tags
.replace(RE_START_MARKER, "<mark>")
.replace(RE_END_MARTKER, "</mark>");
}
return !!res;
};
}
tree.filterMode = (_a = opts.mode) !== null && _a !== void 0 ? _a : "dim";
// eslint-disable-next-line prefer-rest-params
this.lastFilterArgs = arguments;
tree.element.classList.toggle("wb-ext-filter-hide", !!hideMode);
tree.element.classList.toggle("wb-ext-filter-dim", opts.mode === "dim");
tree.element.classList.toggle("wb-ext-filter-hide-expanders", !!opts.hideExpanders);
// Reset current filter
tree.root.subMatchCount = 0;
tree.visit((node) => {
delete node.match;
delete node.titleWithHighlight;
node.subMatchCount = 0;
});
tree.setStatus(NodeStatusType.ok);
// Adjust node.hide, .match, and .subMatchCount properties
treeOpts.autoCollapse = false; // #528
tree.visit((node) => {
if (leavesOnly && node.children != null) {
return;
}
let res = filter(node);
if (res === "skip") {
node.visit(function (c) {
c.match = undefined;
}, true);
return "skip";
}
let matchedByBranch = false;
if ((matchBranch || res === "branch") && node.parent.match) {
res = true;
matchedByBranch = true;
}
if (res) {
count++;
node.match = count;
node.visitParents((p) => {
if (p !== node) {
p.subMatchCount += 1;
}
// Expand match (unless this is no real match, but only a node in a matched branch)
if (opts.autoExpand && !matchedByBranch && !p.expanded) {
p.setExpanded(true, {
noAnimation: true,
noEvents: true,
});
p._filterAutoExpanded = true;
}
}, true);
}
});
treeOpts.autoCollapse = prevAutoCollapse;
if (count === 0 && opts.noData && hideMode) {
if (typeof opts.noData === "string") {
tree.root.setStatus(NodeStatusType.noData, { message: opts.noData });
}
else {
tree.root.setStatus(NodeStatusType.noData);
}
}
// Redraw whole tree
tree.logDebug(`Filter '${filter}' found ${count} nodes in ${Date.now() - start} ms.`);
this._updatedConnectedControls();
return count;
}
/**
* [ext-filter] Dim or hide nodes.
*/
filterNodes(filter, options) {
return this._applyFilterNoUpdate(filter, options);
}
/**
* [ext-filter] Dim or hide whole branches.
* @deprecated Use {@link filterNodes} instead and set `options.matchBranch: true`.
*/
filterBranches(filter, options) {
assert(options.matchBranch === undefined, "filterBranches() is deprecated.");
options.matchBranch = true;
return this._applyFilterNoUpdate(filter, options);
}
/**
* [ext-filter] Return the number of matched nodes.
*/
countMatches() {
let n = 0;
this.tree.visit((node) => {
if (node.match && !node.statusNodeType) {
n++;
}
});
return n;
}
/**
* [ext-filter] Re-apply current filter.
*/
updateFilter() {
var _a;
const tree = this.tree;
if (tree.filterMode &&
this.lastFilterArgs &&
((_a = tree.options.filter) === null || _a === void 0 ? void 0 : _a.autoApply)) {
// eslint-disable-next-line prefer-spread
this._applyFilterNoUpdate.apply(this, this.lastFilterArgs);
}
else {
tree.logWarn("updateFilter(): no filter active.");
}
this._updatedConnectedControls();
}
/**
* [ext-filter] Reset the filter.
*/
clearFilter() {
const tree = this.tree;
tree.enableUpdate(false);
tree.setStatus(NodeStatusType.ok);
// we also counted root node's subMatchCount
delete tree.root.match;
delete tree.root.subMatchCount;
tree.visit((node) => {
delete node.match;
delete node.subMatchCount;
delete node.titleWithHighlight;
if (node._filterAutoExpanded && node.expanded) {
node.setExpanded(false, {
noAnimation: true,
noEvents: true,
});
}
delete node._filterAutoExpanded;
});
tree.filterMode = null;
this.lastFilterArgs = null;
tree.element.classList.remove(
// "wb-ext-filter",
"wb-ext-filter-dim", "wb-ext-filter-hide");
this._updatedConnectedControls();
tree.enableUpdate(true);
}
}
/**
* @description Marks the matching charecters of `text` either by `mark` or
* by exotic*Chars (if `escapeTitles` is `true`) based on `matches`
* which is an array of matching groups.
* @param {string} text
* @param {RegExpMatchArray} matches
*/
function _markFuzzyMatchedChars(text, matches, escapeTitles = true) {
const matchingIndices = [];
// get the indices of matched characters (Iterate through `RegExpMatchArray`)
for (let _matchingArrIdx = 1; _matchingArrIdx < matches.length; _matchingArrIdx++) {
const _mIdx =
// get matching char index by cumulatively adding
// the matched group length
matches[_matchingArrIdx].length +
(_matchingArrIdx === 1 ? 0 : 1) +
(matchingIndices[matchingIndices.length - 1] || 0);
matchingIndices.push(_mIdx);
}
// Map each `text` char to its position and store in `textPoses`.
const textPoses = text.split("");
if (escapeTitles) {
// If escaping the title, then wrap the matching char within exotic chars
matchingIndices.forEach(function (v) {
textPoses[v] = START_MARKER + textPoses[v] + END_MARKER;
});
}
else {
// Otherwise, Wrap the matching chars within `mark`.
matchingIndices.forEach(function (v) {
textPoses[v] = "<mark>" + textPoses[v] + "</mark>";
});
}
// Join back the modified `textPoses` to create final highlight markup.
return textPoses.join("");
}
/*!
* Wunderbaum - ext-keynav
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
const QUICKSEARCH_DELAY = 500;
class KeynavExtension extends WunderbaumExtension {
constructor(tree) {
super(tree, "keynav", {});
}
_getEmbeddedInputElem(elem) {
var _a;
let input = null;
if (elem && elem.type != null) {
input = elem;
}
else {
// ,[contenteditable]
const ace = (_a = this.tree.getActiveColElem()) === null || _a === void 0 ? void 0 : _a.querySelector("input,select");
if (ace) {
input = ace;
}
}
return input;
}
// /* Return the current cell's embedded input that has keyboard focus. */
// protected _getFocusedInputElem(): HTMLInputElement | null {
// const ace = this.tree
// .getActiveColElem()
// ?.querySelector<HTMLInputElement>("input:focus,select:focus");
// return ace || null;
// }
/* Return true if the current cell's embedded input has keyboard focus. */
_isCurInputFocused() {
var _a;
const ace = (_a = this.tree
.getActiveColElem()) === null || _a === void 0 ? void 0 : _a.querySelector("input:focus,select:focus");
return !!ace;
}
onKeyEvent(data) {
const event = data.event;
const tree = this.tree;
const opts = data.options;
const activate = !event.ctrlKey || opts.autoActivate;
const curInput = this._getEmbeddedInputElem(event.target);
const inputHasFocus = curInput && this._isCurInputFocused();
const navModeOption = opts.navigationModeOption;
let focusNode, eventName = eventToString(event), node = data.node, handled = true;
// tree.log(`onKeyEvent: ${eventName}, curInput`, curInput);
if (!tree.isEnabled()) {
// tree.logDebug(`onKeyEvent ignored for disabled tree: ${eventName}`);
return false;
}
// Let callback prevent default processing
if (tree._callEvent("keydown", data) === false) {
return false;
}
// Let ext-edit trigger editing
if (tree._callMethod("edit._preprocessKeyEvent", data) === false) {
return false;
}
// Set focus to active (or first node) if no other node has the focus yet
if (!node) {
const currentNode = tree.getFocusNode() || tree.getActiveNode();
const firstNode = tree.getFirstChild();
if (!currentNode && firstNode && eventName === "ArrowDown") {
firstNode.logInfo("Keydown: activate first node.");
firstNode.setActive();
return;
}
focusNode = currentNode || firstNode;
if (focusNode) {
focusNode.setFocus();
node = tree.getFocusNode();
node.logInfo("Keydown: force focus on active node.");
}
}
const isColspan = node.isColspan();
if (tree.isRowNav()) {
// -----------------------------------------------------------------------
// --- Row Mode ---
// -----------------------------------------------------------------------
if (inputHasFocus) {
// If editing an embedded input control, let the control handle all
// keys. Only Enter and Escape should apply / discard, but keep the
// keyboard focus.
switch (eventName) {
case "Enter":
curInput.blur();
tree.setFocus();
break;
case "Escape":
node._render();
tree.setFocus();
break;
}
return;
}
// --- Quick-Search
if (opts.quicksearch &&
eventName.length === 1 &&
/^\w$/.test(eventName) &&
!curInput) {
// Allow to search for longer streaks if typed in quickly
const stamp = Date.now();
if (stamp - tree.lastQuicksearchTime > QUICKSEARCH_DELAY) {
tree.lastQuicksearchTerm = "";
}
tree.lastQuicksearchTime = stamp;
tree.lastQuicksearchTerm += eventName;
const matchNode = tree.findNextNode(tree.lastQuicksearchTerm, tree.getActiveNode());
if (matchNode) {
matchNode.setActive(true, { event: event });
}
event.preventDefault();
return;
}
// Pre-Evaluate expand/collapse action for LEFT/RIGHT
switch (eventName) {
case "Enter":
if (node.isActive()) {
if (node.isExpanded()) {
eventName = "Subtract"; // callapse
}
else if (node.isExpandable(true)) {
eventName = "Add"; // expand
}
}
break;
case "ArrowLeft":
if (node.expanded) {
eventName = "Subtract"; // collapse
}
break;
case "ArrowRight":
if (!node.expanded && node.isExpandable(true)) {
eventName = "Add"; // expand
}
else if (navModeOption === NavModeEnum.startCell ||
navModeOption === NavModeEnum.startRow) {
event.preventDefault();
tree.setCellNav();
return false;
}
break;
}
// Standard navigation (row mode)
switch (eventName) {
case "+":
case "Add":
// case "=": // 187: '+' @ Chrome, Safari
node.setExpanded(true);
break;
case "-":
case "Subtract":
node.setExpanded(false);
break;
case " ": // Space
// if (node.isPagingNode()) {
// tree._triggerNodeEvent("clickPaging", ctx, event);
// } else
if (node.getOption("checkbox")) {
node.toggleSelected();
}
else {
node.setActive(true, { event: event });
}
break;
case "Enter":
node.setActive(true, { event: event });
break;
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "Backspace":
case "End":
case "Home":
case "Control+End":
case "Control+Home":
case "Meta+ArrowDown":
case "Meta+ArrowUp":
case "PageDown":
case "PageUp":
node.navigate(eventName, { activate: activate, event: event });
break;
default:
handled = false;
}
}
else {
// -----------------------------------------------------------------------
// --- Cell Mode ---
// -----------------------------------------------------------------------
// // Standard navigation (cell mode)
// if (isCellEditMode && INPUT_BREAKOUT_KEYS.has(eventName)) {
// }
// const curInput = this._getEmbeddedInputElem(null);
const curInputType = curInput ? curInput.type || curInput.tagName : "";
// const inputHasFocus = curInput && this._isCurInputFocused();
const inputCanFocus = curInput && curInputType !== "checkbox";
if (inputHasFocus) {
if (eventName === "Escape") {
node.logDebug(`Reset focused input on Escape`);
// Discard changes and reset input validation state
curInput.setCustomValidity("");
node._render();
// Keep cell-nav mode
tree.setFocus();
tree.setColumn(tree.activeColIdx);
return;
// } else if (!INPUT_BREAKOUT_KEYS.has(eventName)) {
}
else if (eventName !== "Enter") {
if (curInput && curInput.checkValidity && !curInput.checkValidity()) {
// Invalid input: ignore all keys except Enter and Escape
node.logDebug(`Ignored ${eventName} inside invalid input`);
return false;
}
// Let current `<input>` handle it
node.logDebug(`Ignored ${eventName} inside focused input`);
return;
}
// const curInputType = curInput.type || curInput.tagName;
// const breakoutKeys = INPUT_KEYS[curInputType];
// if (!breakoutKeys.includes(eventName)) {
// node.logDebug(`Ignored ${eventName} inside ${curInputType} input`);
// return;
// }
}
else if (curInput) {
// On a cell that has an embedded, unfocused <input>
if (eventName.length === 1 && inputCanFocus) {
// Typing a single char
curInput.focus();
curInput.value = "";
node.logDebug(`Focus input: ${eventName}`);
return false;
}
}
if (eventName === "Tab") {
eventName = "ArrowRight";
handled = true;
}
else if (eventName === "Shift+Tab") {
eventName = tree.activeColIdx > 0 ? "ArrowLeft" : "";
handled = true;
}
switch (eventName) {
case "+":
case "Add":
// case "=": // 187: '+' @ Chrome, Safari
node.setExpanded(true);
break;
case "-":
case "Subtract":
node.setExpanded(false);
break;
case " ": // Space
if (tree.activeColIdx === 0 && node.getOption("checkbox")) {
node.toggleSelected();
handled = true;
}
else if (curInput && curInputType === "checkbox") {
curInput.click();
// toggleCheckbox(curInput)
// new Event("change")
// curInput.change
handled = true;
}
break;
case "F2":
if (curInput && !inputHasFocus && inputCanFocus) {
curInput.focus();
handled = true;
}
break;
case "Enter":
tree.setFocus(); // Blur prev. input if any
if ((tree.activeColIdx === 0 || isColspan) && node.isExpandable()) {
node.setExpanded(!node.isExpanded());
handled = true;
}
else if (curInput && !inputHasFocus && inputCanFocus) {
curInput.focus();
handled = true;
}
break;
case "Escape":
tree.setFocus(); // Blur prev. input if any
node.log(`keynav: focus tree...`);
if (tree.isCellNav() && navModeOption !== NavModeEnum.cell) {
node.log(`keynav: setCellNav(false)`);
tree.setCellNav(false); // row-nav mode
tree.setFocus(); //
handled = true;
}
break;
case "ArrowLeft":
tree.setFocus(); // Blur prev. input if any
if (isColspan && node.isExpanded()) {
node.setExpanded(false);
}
else if (!isColspan && tree.activeColIdx > 0) {
tree.setColumn(tree.activeColIdx - 1);
}
else if (navModeOption !== NavModeEnum.cell) {
tree.setCellNav(false); // row-nav mode
}
handled = true;
break;
case "ArrowRight":
tree.setFocus(); // Blur prev. input if any
if (isColspan && !node.isExpanded()) {
node.setExpanded();
}
else if (!isColspan &&
tree.activeColIdx < tree.columns.length - 1) {
tree.setColumn(tree.activeColIdx + 1);
}
handled = true;
break;
case "Home": // Generated by [Fn] + ArrowLeft on Mac
// case "Meta+ArrowLeft":
tree.setFocus(); // Blur prev. input if any
if (!isColspan && tree.activeColIdx > 0) {
tree.setColumn(0);
}
handled = true;
break;
case "End": // Generated by [Fn] + ArrowRight on Mac
// case "Meta+ArrowRight":
tree.setFocus(); // Blur prev. input if any
if (!isColspan && tree.activeColIdx < tree.columns.length - 1) {
tree.setColumn(tree.columns.length - 1);
}
handled = true;
break;
case "ArrowDown":
case "ArrowUp":
case "Backspace":
case "Control+End": // Generated by Control + [Fn] + ArrowRight on Mac
case "Control+Home": // Generated by Control + [Fn] + Arrowleft on Mac
case "Meta+ArrowDown": // [⌘] + ArrowDown on Mac
case "Meta+ArrowUp": // [⌘] + ArrowUp on Mac
case "PageDown": // Generated by [Fn] + ArrowDown on Mac
case "PageUp": // Generated by [Fn] + ArrowUp on Mac
node.navigate(eventName, { activate: activate, event: event });
// if (isCellEditMode) {
// this._getEmbeddedInputElem(null, true); // set focus to input
// }
handled = true;
break;
default:
handled = false;
}
}
if (handled) {
event.preventDefault();
}
return;
}
}
/*!
* Wunderbaum - ext-logger
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
class LoggerExtension extends WunderbaumExtension {
constructor(tree) {
super(tree, "logger", {});
this.ignoreEvents = new Set([
"iconBadge",
// "enhanceTitle",
"render",
"discard",
]);
this.prefix = tree + ".ext-logger";
}
init() {
const tree = this.tree;
// this.ignoreEvents.add();
if (tree.getOption("debugLevel") >= 4) {
// const self = this;
const ignoreEvents = this.ignoreEvents;
const prefix = this.prefix;
overrideMethod(tree, "callEvent", function (name, extra) {
/* eslint-disable prefer-rest-params */
if (ignoreEvents.has(name)) {
return tree._superApply(arguments);
}
const start = Date.now();
const res = tree._superApply(arguments);
tree.logDebug(`${prefix}: callEvent('${name}') took ${Date.now() - start} ms.`, arguments[1]);
return res;
});
}
}
onKeyEvent(data) {
// this.tree.logInfo("onKeyEvent", eventToString(data.event), data);
this.tree.logDebug(`${this.prefix}: onKeyEvent()`, data);
return;
}
}
/*!
* Wunderbaum - ext-dnd
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
const nodeMimeType = "application/x-wunderbaum-node";
class DndExtension extends WunderbaumExtension {
constructor(tree) {
super(tree, "dnd", {
autoExpandMS: 1500, // Expand nodes after n milliseconds of hovering
// dropMarkerInsertOffsetX: -16, // Additional offset for drop-marker with hitMode = "before"/"after"
// dropMarkerOffsetX: -24, // Absolute position offset for .fancytree-drop-marker relatively to ..fancytree-title (icon/img near a node accepting drop)
// #1021 `document.body` is not available yet
// dropMarkerParent: "body", // Root Container used for drop marker (could be a shadow root)
multiSource: false, // true: Drag multiple (i.e. selected) nodes. Also a callback() is allowed
effectAllowed: "all", // Restrict the possible cursor shapes and modifier operations (can also be set in the dragStart event)
dropEffectDefault: "move", // Default dropEffect ('copy', 'link', or 'move') when no modifier is pressed (override in drag, dragOver).
guessDropEffect: true, // Calculate from `effectAllowed` and modifier keys)
preventForeignNodes: false, // Prevent dropping nodes from different Wunderbaum trees
preventLazyParents: true, // Prevent dropping items on unloaded lazy Wunderbaum tree nodes
preventNonNodes: false, // Prevent dropping items other than Wunderbaum tree nodes
preventRecursion: true, // Prevent dropping nodes on own descendants
preventSameParent: false, // Prevent dropping nodes under same direct parent
preventVoidMoves: true, // Prevent dropping nodes 'before self', etc. (move only)
serializeClipboardData: true, // Serialize node data to dataTransfer object
scroll: true, // Enable auto-scrolling while dragging
scrollSensitivity: 20, // Active top/bottom margin in pixel
// scrollnterval: 50, // Generate event every 50 ms
scrollSpeed: 5, // Scroll pixel per 50 ms
// setTextTypeJson: false, // Allow dragging of nodes to different IE windows
sourceCopyHook: null, // Optional callback passed to `toDict` on dragStart @since 2.38
// Events (drag support)
dragStart: null, // Callback(sourceNode, data), return true, to enable dnd drag
drag: null, // Callback(sourceNode, data)
dragEnd: null, // Callback(sourceNode, data)
// Events (drop support)
dragEnter: null, // Callback(targetNode, data), return true, to enable dnd drop
dragOver: null, // Callback(targetNode, data)
dragExpand: null, // Callback(targetNode, data), return false to prevent autoExpand
drop: null, // Callback(targetNode, data)
dragLeave: null, // Callback(targetNode, data)
});
// public dropMarkerElem?: HTMLElement;
this.srcNode = null;
this.lastTargetNode = null;
this.lastEnterStamp = 0;
this.lastAllowedDropRegions = null;
this.lastDropEffect = null;
this.lastDropRegion = false;
this.currentScrollDir = 0;
this.applyScrollDirThrottled = throttle(this._applyScrollDir, 50);
}
init() {
super.init();
// Store the current scroll parent, which may be the tree
// container, any enclosing div, or the document.
// #761: scrollParent() always needs a container child
// $temp = $("<span>").appendTo(this.$container);
// this.$scrollParent = $temp.scrollParent();
// $temp.remove();
const tree = this.tree;
const dndOpts = tree.options.dnd;
// Enable drag support if dragStart() is specified:
if (dndOpts.dragStart) {
onEvent(tree.element, "dragstart drag dragend", this.onDragEvent.bind(this));
}
// Enable drop support if dragEnter() is specified:
if (dndOpts.dragEnter) {
onEvent(tree.element, "dragenter dragover dragleave drop", this.onDropEvent.bind(this));
}
}
/** Cleanup classes after target node is no longer hovered. */
_leaveNode() {
// We remove the marker on dragenter from the previous target:
const ltn = this.lastTargetNode;
this.lastEnterStamp = 0;
if (ltn) {
ltn.setClass("wb-drop-target wb-drop-over wb-drop-after wb-drop-before", false);
this.lastTargetNode = null;
}
}
/** */
unifyDragover(res) {
if (res === false) {
return false;
}
else if (res instanceof Set) {
return res.size > 0 ? res : false;
}
else if (res === true) {
return new Set(["over", "before", "after"]);
}
else if (typeof res === "string" || isArray(res)) {
res = toSet(res);
return res.size > 0 ? res : false;
}
throw new Error("Unsupported drop region definition: " + res);
}
/**
* Calculates the drop region based on the drag event and the allowed drop regions.
*/
_calcDropRegion(e, allowed) {
const rowHeight = this.tree.options.rowHeightPx;
const dy = e.offsetY;
if (!allowed) {
return false;
}
else if (allowed.size === 3) {
return dy < 0.25 * rowHeight
? "before"
: dy > 0.75 * rowHeight
? "after"
: "over";
}
else if (allowed.size === 1 && allowed.has("over")) {
return "over";
}
else {
// Only 'before' and 'after':
return dy > rowHeight / 2 ? "after" : "before";
}
// return "over";
}
/**
* Guess drop effect (copy/link/move) using opinionated conventions.
*
* Default: dnd.dropEffectDefault
*/
_guessDropEffect(e) {
// const nativeDropEffect = e.dataTransfer?.dropEffect;
var _a;
// if (nativeDropEffect && nativeDropEffect !== "none") {
// return nativeDropEffect;
// }
const dndOpts = this.treeOpts.dnd;
const ea = (_a = dndOpts.effectAllowed) !== null && _a !== void 0 ? _a : "all";
const canCopy = ["all", "copy", "copyLink", "copyMove"].includes(ea);
const canLink = ["all", "link", "copyLink", "linkMove"].includes(ea);
const canMove = ["all", "move", "copyMove", "linkMove"].includes(ea);
let res = dndOpts.dropEffectDefault;
if (dndOpts.guessDropEffect) {
if (isMac) {
if (e.altKey && canCopy) {
res = "copy";
}
if (e.metaKey && canMove) {
res = "move"; // command key
}
if (e.altKey && e.metaKey && canLink) {
res = "link";
}
}
else {
if (e.ctrlKey && canCopy) {
res = "copy";
}
if (e.shiftKey && canMove) {
res = "move";
}
if (e.altKey && canLink) {
res = "link";
}
}
}
return res;
}
/** Don't allow void operation ('drop on self').*/
_isVoidDrop(targetNode, srcNode, dropRegion) {
// this.tree.logDebug(
// `_isVoidDrop: ${srcNode} -> ${dropRegion} ${targetNode}`
// );
// TODO: should be checked on move only
if (!this.treeOpts.dnd.preventVoidMoves || !srcNode) {
return false;
}
if ((dropRegion === "before" && targetNode === srcNode.getNextSibling()) ||
(dropRegion === "after" && targetNode === srcNode.getPrevSibling())) {
// this.tree.logDebug("Prevented before/after self");
return true;
}
// Don't allow dropping nodes on own parent (or self)
return srcNode === targetNode || srcNode.parent === targetNode;
}
/* Implement auto scrolling when drag cursor is in top/bottom area of scroll parent. */
_applyScrollDir() {
if (this.isDragging() && this.currentScrollDir) {
const dndOpts = this.tree.options.dnd;
const sp = this.tree.element; // scroll parent
const scrollTop = sp.scrollTop;
if (this.currentScrollDir < 0) {
sp.scrollTop = Math.max(0, scrollTop - dndOpts.scrollSpeed);
}
else if (this.currentScrollDir > 0) {
sp.scrollTop = scrollTop + dndOpts.scrollSpeed;
}
}
}
/* Implement auto scrolling when drag cursor is in top/bottom area of scroll parent. */
_autoScroll(viewportY) {
const tree = this.tree;
const dndOpts = tree.options.dnd;
const sensitivity = dndOpts.scrollSensitivity;
const sp = tree.element; // scroll parent
const headerHeight = tree.headerElement.clientHeight; // May be 0
// const height = sp.clientHeight - headerHeight;
// const height = sp.offsetHeight + headerHeight;
const height = sp.offsetHeight;
const scrollTop = sp.scrollTop;
// tree.logDebug(
// `autoScroll: height=${height}, scrollTop=${scrollTop}, viewportY=${viewportY}`
// );
this.currentScrollDir = 0;
if (scrollTop > 0 &&
viewportY > 0 &&
viewportY <= sensitivity + headerHeight) {
// Mouse in top 20px area: scroll up
// sp.scrollTop = Math.max(0, scrollTop - dndOpts.scrollSpeed);
this.currentScrollDir = -1;
}
else if (scrollTop < sp.scrollHeight - height &&
viewportY >= height - sensitivity) {
// Mouse in bottom 20px area: scroll down
// sp.scrollTop = scrollTop + dndOpts.scrollSpeed;
this.currentScrollDir = 1;
}
if (this.currentScrollDir) {
this.applyScrollDirThrottled();
}
return sp.scrollTop - scrollTop;
}
/** Return true if a drag operation currently in progress. */
isDragging() {
return !!this.srcNode;
}
/**
* Handle dragstart, drag and dragend events for the source node.
*/
onDragEvent(e) {
var _a;
// const tree = this.tree;
const dndOpts = this.treeOpts.dnd;
const srcNode = Wunderbaum.getNode(e);
if (!srcNode) {
this.tree.logWarn(`onDragEvent.${e.type}: no node`);
return;
}
if (["dragstart", "dragend"].includes(e.type)) {
this.tree.logDebug(`onDragEvent.${e.type} srcNode: ${srcNode}`, e);
}
// --- dragstart ---
if (e.type === "dragstart") {
// Set a default definition of allowed effects
e.dataTransfer.effectAllowed = dndOpts.effectAllowed; //"copyMove"; // "all";
if (srcNode.isEditingTitle()) {
srcNode.logDebug("Prevented dragging node in edit mode.");
e.preventDefault();
return false;
}
// Let user cancel the drag operation, override effectAllowed, etc.:
const res = srcNode._callEvent("dnd.dragStart", { event: e });
if (!res) {
e.preventDefault();
return false;
}
const nodeData = srcNode.toDict(true, (n) => {
// We don't want to re-use the key on drop:
n._orgKey = n.key;
delete n.key;
});
nodeData._treeId = srcNode.tree.id;
if (dndOpts.serializeClipboardData) {
if (typeof dndOpts.serializeClipboardData === "function") {
e.dataTransfer.setData(nodeMimeType, dndOpts.serializeClipboardData(nodeData, srcNode));
}
else {
e.dataTransfer.setData(nodeMimeType, JSON.stringify(nodeData));
}
}
// e.dataTransfer!.setData("text/html", $(node.span).html());
if (!((_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.types.includes("text/plain"))) {
e.dataTransfer.setData("text/plain", srcNode.title);
}
this.srcNode = srcNode;
setTimeout(() => {
// Decouple this call, so the CSS is applied to the node, but not to
// the system generated drag image
srcNode.setClass("wb-drag-source");
}, 0);
// --- drag ---
}
else if (e.type === "drag") {
if (dndOpts.drag) {
srcNode._callEvent("dnd.drag", { event: e });
}
// --- dragend ---
}
else if (e.type === "dragend") {
srcNode.setClass("wb-drag-source", false);
this.srcNode = null;
if (this.lastTargetNode) {
this._leaveNode();
}
srcNode._callEvent("dnd.dragEnd", { event: e });
}
return true;
}
/**
* Handle dragenter, dragover, dragleave, drop events.
*/
onDropEvent(e) {
var _a;
// const isLink = event.dataTransfer.types.includes("text/uri-list");
const srcNode = this.srcNode;
const srcTree = srcNode ? srcNode.tree : null;
const targetNode = Wunderbaum.getNode(e);
const dndOpts = this.treeOpts.dnd;
const dt = e.dataTransfer;
const dropRegion = this._calcDropRegion(e, this.lastAllowedDropRegions);
/** Helper to log a message if predicate is false. */
const _t = (pred, msg) => {
if (pred) {
this.tree.log(`Prevented drop operation (${msg}).`);
}
return pred;
};
if (!targetNode) {
this._leaveNode();
return;
}
if (["drop"].includes(e.type)) {
this.tree.logDebug(`onDropEvent.${e.type} targetNode: ${targetNode}, ea: ${dt === null || dt === void 0 ? void 0 : dt.effectAllowed}, ` +
`de: ${dt === null || dt === void 0 ? void 0 : dt.dropEffect}, cy: ${e.offsetY}, r: ${dropRegion}, srcNode: ${srcNode}`, e);
}
// --- dragenter ---
if (e.type === "dragenter") {
// this.tree.logWarn(` onDropEvent.${e.type} targetNode: ${targetNode}`, e);
this.lastAllowedDropRegions = null;
// `dragleave` is not reliable with event delegation, so we generate it
// from dragenter:
if (this.lastTargetNode && this.lastTargetNode !== targetNode) {
this._leaveNode();
}
this.lastTargetNode = targetNode;
this.lastEnterStamp = Date.now();
if (
// Don't drop on status node:
_t(targetNode.isStatusNode(), "is status node") ||
// Prevent dropping nodes from different Wunderbaum trees:
_t(dndOpts.preventForeignNodes && targetNode.tree !== srcTree, "preventForeignNodes") ||
// Prevent dropping items on unloaded lazy Wunderbaum tree nodes:
_t(dndOpts.preventLazyParents && !targetNode.isLoaded(), "preventLazyParents") ||
// Prevent dropping items other than Wunderbaum tree nodes:
_t(dndOpts.preventNonNodes && !srcNode, "preventNonNodes") ||
// Prevent dropping nodes on own descendants:
_t(dndOpts.preventRecursion && (srcNode === null || srcNode === void 0 ? void 0 : srcNode.isAncestorOf(targetNode)), "preventRecursion") ||
// Prevent dropping nodes under same direct parent:
_t(dndOpts.preventSameParent &&
srcNode &&
targetNode.parent === srcNode.parent, "preventSameParent") ||
// Don't allow void operation ('drop on self'): TODO: should be checked on move only
_t(dndOpts.preventVoidMoves && targetNode === srcNode, "preventVoidMoves")) {
dt.dropEffect = "none";
// this.tree.log("Prevented drop operation");
return true; // Prevent drop operation
}
// User may return a set of regions (or `false` to prevent drop)
// Figure out a drop effect (copy/link/move) using opinated conventions.
dt.dropEffect = this._guessDropEffect(e) || "none";
let regionSet = targetNode._callEvent("dnd.dragEnter", {
event: e,
sourceNode: srcNode,
});
//
regionSet = this.unifyDragover(regionSet);
if (!regionSet) {
dt.dropEffect = "none";
return true; // Prevent drop operation
}
this.lastAllowedDropRegions = regionSet;
this.lastDropEffect = dt.dropEffect;
const region = this._calcDropRegion(e, this.lastAllowedDropRegions);
targetNode.setClass("wb-drop-target");
targetNode.setClass("wb-drop-over", region === "over");
targetNode.setClass("wb-drop-before", region === "before");
targetNode.setClass("wb-drop-after", region === "after");
e.preventDefault(); // Allow drop (Drop operation is denied by default)
return false;
// --- dragover ---
}
else if (e.type === "dragover") {
const viewportY = e.clientY - this.tree.element.offsetTop;
this._autoScroll(viewportY);
dt.dropEffect = this._guessDropEffect(e) || "none";
targetNode._callEvent("dnd.dragOver", { event: e, sourceNode: srcNode });
const region = this._calcDropRegion(e, this.lastAllowedDropRegions);
this.lastDropRegion = region;
this.lastDropEffect = dt.dropEffect;
if (dndOpts.autoExpandMS > 0 &&
targetNode.isExpandable(true) &&
!targetNode._isLoading &&
Date.now() - this.lastEnterStamp > dndOpts.autoExpandMS &&
targetNode._callEvent("dnd.dragExpand", {
event: e,
sourceNode: srcNode,
}) !== false) {
targetNode.setExpanded();
}
if (!region || this._isVoidDrop(targetNode, srcNode, region)) {
return; // We already rejected in dragenter
}
targetNode.setClass("wb-drop-over", region === "over");
targetNode.setClass("wb-drop-before", region === "before");
targetNode.setClass("wb-drop-after", region === "after");
e.preventDefault(); // Allow drop (Drop operation is denied by default)
return false;
// --- dragleave ---
}
else if (e.type === "dragleave") {
// NOTE: we cannot trust this event, since it is always fired,
// Instead we remove the marker on dragenter
targetNode._callEvent("dnd.dragLeave", { event: e, sourceNode: srcNode });
// --- drop ---
}
else if (e.type === "drop") {
e.stopPropagation(); // prevent browser from opening links?
e.preventDefault(); // #69 prevent iOS browser from opening links
this._leaveNode();
const region = this.lastDropRegion;
let nodeData = (_a = e.dataTransfer) === null || _a === void 0 ? void 0 : _a.getData(nodeMimeType);
nodeData = nodeData ? JSON.parse(nodeData) : null;
const srcNode = this.srcNode;
const lastDropEffect = this.lastDropEffect;
setTimeout(() => {
// Decouple this call, because drop actions may prevent the dragend event
// from being fired on some browsers
targetNode._callEvent("dnd.drop", {
event: e,
region: region,
suggestedDropMode: region === "over" ? "appendChild" : region,
suggestedDropEffect: lastDropEffect,
// suggestedDropEffect: e.dataTransfer?.dropEffect,
sourceNode: srcNode,
sourceNodeData: nodeData,
});
}, 10);
}
return false;
}
}
/*!
* Wunderbaum - drag_observer
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
/**
* Convert mouse- and touch events to 'dragstart', 'drag', and 'dragstop'.
*/
class DragObserver {
constructor(opts) {
this.start = {
event: null,
x: 0,
y: 0,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
};
this.dragElem = null;
this.dragging = false;
this.customData = {};
// TODO: touch events
this.events = ["mousedown", "mouseup", "mousemove", "keydown"];
if (!opts.root) {
throw new Error("Missing `root` option.");
}
this.opts = Object.assign({ thresh: 5 }, opts);
this.root = opts.root;
this._handler = this.handleEvent.bind(this);
this.events.forEach((type) => {
this.root.addEventListener(type, this._handler);
});
}
/** Unregister all event listeners. */
disconnect() {
this.events.forEach((type) => {
this.root.removeEventListener(type, this._handler);
});
}
getDragElem() {
return this.dragElem;
}
isDragging() {
return this.dragging;
}
stopDrag(cb_event) {
if (this.dragging && this.opts.dragstop && cb_event) {
cb_event.type = "dragstop";
try {
this.opts.dragstop(cb_event);
}
catch (err) {
console.error("dragstop error", err); // eslint-disable-line no-console
}
}
this.dragElem = null;
this.dragging = false;
this.start.event = null;
this.customData = {};
}
handleEvent(e) {
const type = e.type;
const opts = this.opts;
const cb_event = {
type: e.type,
startEvent: type === "mousedown" ? e : this.start.event,
event: e,
customData: this.customData,
dragElem: this.dragElem,
dx: e.pageX - this.start.x,
dy: e.pageY - this.start.y,
apply: undefined,
};
// console.log("handleEvent", type, cb_event);
switch (type) {
case "keydown":
this.stopDrag(cb_event);
break;
case "mousedown":
if (this.dragElem) {
this.stopDrag(cb_event);
break;
}
if (opts.selector) {
let elem = e.target;
if (elem.matches(opts.selector)) {
this.dragElem = elem;
}
else {
elem = elem.closest(opts.selector);
if (elem) {
this.dragElem = elem;
}
else {
break; // no event delegation selector matched
}
}
}
this.start.event = e;
this.start.x = e.pageX;
this.start.y = e.pageY;
this.start.altKey = e.altKey;
this.start.ctrlKey = e.ctrlKey;
this.start.metaKey = e.metaKey;
this.start.shiftKey = e.shiftKey;
break;
case "mousemove":
// TODO: debounce/throttle?
// TODO: horizontal mode: ignore if dx unchanged
if (!this.dragElem) {
break;
}
if (!this.dragging) {
if (opts.thresh) {
const dist2 = cb_event.dx * cb_event.dx + cb_event.dy * cb_event.dy;
if (dist2 < opts.thresh * opts.thresh) {
break;
}
}
cb_event.type = "dragstart";
if (opts.dragstart(cb_event) === false) {
this.stopDrag(cb_event);
break;
}
this.dragging = true;
}
if (this.dragging && this.opts.drag) {
cb_event.type = "drag";
this.opts.drag(cb_event);
}
break;
case "mouseup":
if (!this.dragging) {
this.stopDrag(cb_event);
break;
}
if (e.button === 0) {
cb_event.apply = true;
}
else {
cb_event.apply = false;
}
this.stopDrag(cb_event);
break;
}
}
}
/*!
* Wunderbaum - common
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
const DEFAULT_DEBUGLEVEL = 3; // Replaced by rollup script
/**
* Fixed height of a row in pixel. Must match the SCSS variable `$row-outer-height`.
*/
const DEFAULT_ROW_HEIGHT = 22;
/**
* Fixed width of node icons in pixel. Must match the SCSS variable `$icon-outer-width`.
*/
const ICON_WIDTH = 20;
/**
* Adjust the width of the title span, so overflow ellipsis work.
* (2 x `$col-padding-x` + 3px rounding errors).
*/
const TITLE_SPAN_PAD_Y = 7;
/** Render row markup for N nodes above and below the visible viewport. */
const RENDER_MAX_PREFETCH = 5;
/** Minimum column width if not set otherwise. */
const DEFAULT_MIN_COL_WIDTH = 4;
/** Regular expression to detect if a string describes an image URL (in contrast
* to a class name). Strings are considered image urls if they contain '.' or '/'.
*/
const TEST_IMG = new RegExp(/\.|\//);
// export const RECURSIVE_REQUEST_ERROR = "$recursive_request";
// export const INVALID_REQUEST_TARGET_ERROR = "$request_target_invalid";
/**
* Default node icons for icon libraries
*
* - 'bootstrap': {@link https://icons.getbootstrap.com}
* - 'fontawesome6' {@link https://fontawesome.com/icons}
*
*/
const iconMaps = {
bootstrap: {
error: "bi bi-exclamation-triangle",
// loading: "bi bi-hourglass-split wb-busy",
loading: "bi bi-chevron-right wb-busy",
// loading: "bi bi-arrow-repeat wb-spin",
// loading: '<div class="spinner-border spinner-border-sm" role="status"> <span class="visually-hidden">Loading...</span> </div>',
// noData: "bi bi-search",
noData: "bi bi-question-circle",
expanderExpanded: "bi bi-chevron-down",
// expanderExpanded: "bi bi-dash-square",
expanderCollapsed: "bi bi-chevron-right",
// expanderCollapsed: "bi bi-plus-square",
expanderLazy: "bi bi-chevron-right wb-helper-lazy-expander",
// expanderLazy: "bi bi-chevron-bar-right",
checkChecked: "bi bi-check-square",
checkUnchecked: "bi bi-square",
checkUnknown: "bi bi-dash-square-dotted",
radioChecked: "bi bi-circle-fill",
radioUnchecked: "bi bi-circle",
radioUnknown: "bi bi-record-circle",
folder: "bi bi-folder2",
folderOpen: "bi bi-folder2-open",
folderLazy: "bi bi-folder-symlink",
doc: "bi bi-file-earmark",
colSortable: "bi bi-chevron-expand",
// colSortable: "bi bi-arrow-down-up",
// colSortAsc: "bi bi-chevron-down",
// colSortDesc: "bi bi-chevron-up",
colSortAsc: "bi bi-arrow-down",
colSortDesc: "bi bi-arrow-up",
colFilter: "bi bi-filter-circle",
colFilterActive: "bi bi-filter-circle-fill wb-helper-invalid",
colMenu: "bi bi-three-dots-vertical",
},
fontawesome6: {
error: "fa-solid fa-triangle-exclamation",
loading: "fa-solid fa-chevron-right fa-beat",
noData: "fa-solid fa-circle-question",
expanderExpanded: "fa-solid fa-chevron-down",
expanderCollapsed: "fa-solid fa-chevron-right",
expanderLazy: "fa-solid fa-chevron-right wb-helper-lazy-expander",
checkChecked: "fa-regular fa-square-check",
checkUnchecked: "fa-regular fa-square",
checkUnknown: "fa-regular fa-square-minus",
radioChecked: "fa-solid fa-circle",
radioUnchecked: "fa-regular fa-circle",
radioUnknown: "fa-regular fa-circle-question",
folder: "fa-solid fa-folder-closed",
folderOpen: "fa-regular fa-folder-open",
folderLazy: "fa-solid fa-folder-plus",
doc: "fa-regular fa-file",
colSortable: "fa-solid fa-fw fa-sort",
colSortAsc: "fa-solid fa-fw fa-sort-up",
colSortDesc: "fa-solid fa-fw fa-sort-down",
colFilter: "fa-solid fa-fw fa-filter",
colFilterActive: "fa-solid fa-fw fa-filter wb-helper-invalid",
colMenu: "fa-solid fa-fw fa-ellipsis-v",
},
};
/** Dict keys that are evaluated by source loader (others are added to `tree.data` instead). */
const RESERVED_TREE_SOURCE_KEYS = new Set([
"_format", // reserved for future use
"_keyMap", // Used for compressed data format
"_positional", // Used for compressed data format
"_typeList", // Used for compressed data format @deprecated
"_valueMap", // Used for compressed data format
"_version", // reserved for future use
"children",
"columns",
"types",
]);
// /** Key codes that trigger grid navigation, even when inside an input element. */
// export const INPUT_BREAKOUT_KEYS: Set<string> = new Set([
// // "ArrowDown",
// // "ArrowUp",
// "Enter",
// "Escape",
// ]);
/** Map `KeyEvent.key` to navigation action. */
const KEY_TO_NAVIGATION_MAP = {
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
Backspace: "parent",
End: "lastCol",
Home: "firstCol",
"Control+End": "last",
"Control+Home": "first",
"Meta+ArrowDown": "last", // macOs
"Meta+ArrowUp": "first", // macOs
PageDown: "pageDown",
PageUp: "pageUp",
};
/** Return a callback that returns true if the node title matches the string
* or regular expression.
* @see {@link WunderbaumNode.findAll}
*/
function makeNodeTitleMatcher(match) {
if (match instanceof RegExp) {
return function (node) {
return match.test(node.title);
};
}
assert(typeof match === "string", `Expected a string or RegExp: ${match}`);
// s = escapeRegex(s.toLowerCase());
return function (node) {
return node.title === match;
// console.log("match " + node, node.title.toLowerCase().indexOf(match))
// return node.title.toLowerCase().indexOf(match) >= 0;
};
}
/** Return a callback that returns true if the node title starts with a string (case-insensitive). */
function makeNodeTitleStartMatcher(s) {
s = escapeRegex(s);
const reMatch = new RegExp("^" + s, "i");
return function (node) {
return reMatch.test(node.title);
};
}
/** Compare two nodes by title (case-insensitive). */
function nodeTitleSorter(a, b) {
const x = a.title.toLowerCase();
const y = b.title.toLowerCase();
return x === y ? 0 : x > y ? 1 : -1;
}
/**
* Convert 'flat' to 'nested' format.
*
* Flat node entry format:
* [PARENT_IDX, {KEY_VALUE_ARGS}]
* or, if N _positional re defined:
* [PARENT_IDX, POSITIONAL_ARG_1, POSITIONAL_ARG_2, ..., POSITIONAL_ARG_N]
* Even if _positional additional are defined, KEY_VALUE_ARGS can be appended:
* [PARENT_IDX, POSITIONAL_ARG_1, ..., {KEY_VALUE_ARGS}]
*
* 1. Parent-referencing list is converted to a list of nested dicts with
* optional `children` properties.
* 2. `[POSITIONAL_ARGS]` are added as dict attributes.
*/
function unflattenSource(source) {
var _a, _b, _c;
const { _format, _keyMap = {}, _positional = [], children } = source;
const _positionalCount = _positional.length;
if (_format !== "flat") {
throw new Error(`Expected source._format: "flat", but got ${_format}`);
}
if (_positionalCount && _positional.includes("children")) {
throw new Error(`source._positional must not include "children": ${_positional}`);
}
let longToShort = _keyMap;
if (_keyMap.t) {
// Inverse keyMap was used (pre 0.7.0)
// TODO: raise Error on final 1.x release
const msg = `source._keyMap maps from long to short since v0.7.0. Flip key/value!`;
console.warn(msg); // eslint-disable-line no-console
longToShort = {};
for (const [key, value] of Object.entries(_keyMap)) {
longToShort[value] = key;
}
}
const positionalShort = _positional.map((e) => { var _a; return (_a = longToShort[e]) !== null && _a !== void 0 ? _a : e; });
const newChildren = [];
const keyToNodeMap = {};
const indexToNodeMap = {};
const keyAttrName = (_a = longToShort["key"]) !== null && _a !== void 0 ? _a : "key";
const childrenAttrName = (_b = longToShort["children"]) !== null && _b !== void 0 ? _b : "children";
for (const [index, nodeTuple] of children.entries()) {
// Node entry format:
// [PARENT_ID, [POSITIONAL_ARGS]]
// or
// [PARENT_ID, POSITIONAL_ARG_1, POSITIONAL_ARG_2, ..., {KEY_VALUE_ARGS}]
let kwargs;
const [parentId, ...args] = nodeTuple;
if (args.length === _positionalCount) {
kwargs = {};
}
else if (args.length === _positionalCount + 1) {
kwargs = args.pop();
if (typeof kwargs !== "object") {
throw new Error(`unflattenSource: Expected dict as last tuple element: ${nodeTuple}`);
}
}
else {
throw new Error(`unflattenSource: unexpected tuple length: ${nodeTuple}`);
}
// Free up some memory as we go
nodeTuple[1] = null;
if (nodeTuple[2] != null) {
nodeTuple[2] = null;
}
// We keep `kwargs` as our new node definition. Then we add all positional
// values to this object:
args.forEach((val, positionalIdx) => {
kwargs[positionalShort[positionalIdx]] = val;
});
args.length = 0;
// Find the parent node. `null` means 'toplevel'. PARENT_ID may be the numeric
// index of the source.children list. If PARENT_ID is a string, we search
// a parent with node.key of this value.
indexToNodeMap[index] = kwargs;
const key = kwargs[keyAttrName];
if (key != null) {
keyToNodeMap[key] = kwargs;
}
let parentNode = null;
if (parentId === null) ;
else if (typeof parentId === "number") {
parentNode = indexToNodeMap[parentId];
if (parentNode === undefined) {
throw new Error(`unflattenSource: Could not find parent node by index: ${parentId}.`);
}
}
else {
parentNode = keyToNodeMap[parentId];
if (parentNode === undefined) {
throw new Error(`unflattenSource: Could not find parent node by key: ${parentId}`);
}
}
if (parentNode) {
(_c = parentNode[childrenAttrName]) !== null && _c !== void 0 ? _c : (parentNode[childrenAttrName] = []);
parentNode[childrenAttrName].push(kwargs);
}
else {
newChildren.push(kwargs);
}
}
source.children = newChildren;
}
/**
* Decompresses the source data by
* - converting from 'flat' to 'nested' format
* - expanding short alias names to long names (if defined in _keyMap)
* - resolving value indexes to value strings (if defined in _valueMap)
*
* @param source - The source object to be decompressed.
* @returns void
*/
function decompressSourceData(source) {
let { _format, _version = 1, _keyMap, _valueMap } = source;
assert(_version === 1, `Expected file version 1 instead of ${_version}`);
let longToShort = _keyMap;
let shortToLong = {};
if (longToShort) {
for (const [key, value] of Object.entries(longToShort)) {
shortToLong[value] = key;
}
}
// Fallback for old format (pre 0.7.0, using _keyMap in reverse direction)
// TODO: raise Error on final 1.x release
if (longToShort && longToShort.t) {
const msg = `source._keyMap maps from long to short since v0.7.0. Flip key/value!`;
console.warn(msg); // eslint-disable-line no-console
[longToShort, shortToLong] = [shortToLong, longToShort];
}
// Fallback for old format (pre 0.7.0, using _typeList instead of _valueMap)
// TODO: raise Error on final 1.x release
if (source._typeList != null) {
const msg = `source._typeList is deprecated since v0.7.0: use source._valueMap: {"type": [...]} instead.`;
if (_valueMap != null) {
throw new Error(msg);
}
else {
console.warn(msg); // eslint-disable-line no-console
_valueMap = { type: source._typeList };
delete source._typeList;
}
}
if (_format === "flat") {
unflattenSource(source);
}
delete source._format;
delete source._version;
delete source._keyMap;
delete source._valueMap;
delete source._positional;
function _iter(childList) {
for (const node of childList) {
// Iterate over a list of names, because we modify inside the loop
// (for ... of ... does not allow this)
Object.getOwnPropertyNames(node).forEach((propName) => {
const value = node[propName];
// Replace short names with long names if defined in _keyMap
let longName = propName;
if (_keyMap && shortToLong[propName] != null) {
longName = shortToLong[propName];
if (longName !== propName) {
node[longName] = value;
delete node[propName];
}
}
// Replace type index with type name if defined in _valueMap
if (_valueMap &&
typeof value === "number" &&
_valueMap[longName] != null) {
const newValue = _valueMap[longName][value];
if (newValue == null) {
throw new Error(`Expected valueMap[${longName}][${value}] entry in [${_valueMap[longName]}]`);
}
node[longName] = newValue;
}
});
// Recursion
if (node.children) {
_iter(node.children);
}
}
}
if (_keyMap || _valueMap) {
_iter(source.children);
}
}
/*!
* Wunderbaum - ext-grid
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
class GridExtension extends WunderbaumExtension {
constructor(tree) {
super(tree, "grid", {
// throttle: 200,
});
this.observer = new DragObserver({
root: window.document,
selector: "span.wb-col-resizer-active",
thresh: 4,
// throttle: 400,
dragstart: (e) => {
const info = Wunderbaum.getEventInfo(e.startEvent);
const colDef = info.colDef;
const allow = colDef &&
this.tree.element.contains(e.dragElem) &&
toBool(colDef.resizable, tree.options.columnsResizable, false);
// this.tree.log("dragstart", colDef, e, info);
this.tree.element.classList.toggle("wb-col-resizing", !!allow);
info.colElem.classList.toggle("wb-col-resizing", !!allow);
// We start dagging, so we remember the actual width in *pixels*
// (which may be 'auto' or '100%').
// Since we we re-create the markup on each update, we also cannot store
// the original event or DOM element, but only the colDef object.
if (allow) {
// Store initial target column infos in customData
e.customData.colDef = colDef;
e.customData.orgCustomWidthPx = colDef.customWidthPx;
const curWidthPx = Number.parseInt(info.colElem.style.width, 10);
e.customData.orgWidthPx = curWidthPx;
// Set custom width to current width, so that we can modify it
colDef.customWidthPx = curWidthPx;
// this.tree.log(
// `dragstart customWidthPx=${colDef.customWidthPx}`,
// e,
// info
// );
this.tree.update(ChangeType.colStructure);
// this.tree.log(
// `dragstart 2 customWidthPx=${colDef.customWidthPx}`,
// e,
// info
// );
}
return allow;
},
drag: (e) => {
// TODO: throttle
return this.handleDrag(e);
},
dragstop: (e) => {
return this.handleDrag(e);
},
});
}
init() {
super.init();
}
/**
* Hanldes drag and sragstop events for column resizing.
*/
handleDrag(e) {
const custom = e.customData;
const colDef = custom.colDef;
// this.tree.log(`${e.type} (dx=${e.dx})`, e, info);
if (e.type === "dragstop" || e.type === "drag") {
this.tree.element.classList.remove("wb-col-resizing");
// info.colElem!.classList.remove("wb-col-resizing");
if (e.apply || e.type === "drag") {
const minWidth = toPixel(colDef.minWidth, DEFAULT_MIN_COL_WIDTH);
const newWidth = Math.max(minWidth, custom.orgWidthPx + e.dx);
colDef.customWidthPx = newWidth;
// this.tree.log(
// `${e.type} minWidth=${minWidth}, newWidth=${newWidth}`,
// colDef
// );
}
else {
// Drag was cancelled
this.tree.log("Column resize cancelled", e);
colDef.customWidthPx = custom.orgCustomWidthPx; // Restore original width or undefined
}
this.tree.update(ChangeType.colStructure);
}
}
}
/*!
* Wunderbaum - deferred
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
/**
* Implement a ES6 Promise, that exposes a resolve() and reject() method.
*
* Loosely mimics {@link https://api.jquery.com/category/deferred-object/ | jQuery.Deferred}.
* Example:
* ```js
* function foo() {
* let dfd = new Deferred(),
* ...
* dfd.resolve('foo')
* ...
* return dfd.promise();
* }
* ```
*/
class Deferred {
constructor() {
this._promise = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
}
/** Resolve the Promise. */
resolve(value) {
this._resolve(value);
}
/** Reject the Promise. */
reject(reason) {
this._reject(reason);
}
/** Return the native Promise instance.*/
promise() {
return this._promise;
}
/** Call Promise.then on the embedded promise instance.*/
then(cb) {
return this._promise.then(cb);
}
/** Call Promise.catch on the embedded promise instance.*/
catch(cb) {
return this._promise.catch(cb);
}
/** Call Promise.finally on the embedded promise instance.*/
finally(cb) {
return this._promise.finally(cb);
}
}
/*!
* Wunderbaum - wunderbaum_node
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
/** WunderbaumNode properties that can be passed with source data.
* (Any other source properties will be stored as `node.data.PROP`.)
*/
const NODE_PROPS = new Set([
"checkbox",
"classes",
"expanded",
"icon",
"iconTooltip",
"key",
"lazy",
"_partsel",
"radiogroup",
"refKey",
"selected",
"statusNodeType",
"title",
"tooltip",
"type",
"unselectable",
]);
/** WunderbaumNode properties that will be returned by `node.toDict()`.)
*/
const NODE_DICT_PROPS = new Set(NODE_PROPS);
NODE_DICT_PROPS.delete("_partsel");
NODE_DICT_PROPS.delete("unselectable");
// /** Node properties that are of type bool (or boolean & string).
// * When parsing, we accept 0 for false and 1 for true for better JSON compression.
// */
// export const NODE_BOOL_PROPS: Set<string> = new Set([
// "checkbox",
// "colspan",
// "expanded",
// "icon",
// "iconTooltip",
// "radiogroup",
// "selected",
// "tooltip",
// "unselectable",
// ]);
/**
* A single tree node.
*
* **NOTE:** <br>
* Generally you should not modify properties directly, since this may break
* the internal bookkeeping.
*/
class WunderbaumNode {
constructor(tree, parent, data) {
var _a, _b;
/** Reference key. Unlike {@link key}, a `refKey` may occur multiple
* times within a tree (in this case we have 'clone nodes').
* @see Use {@link setKey} to modify.
*/
this.refKey = undefined;
/**
* Array of child nodes (null for leaf nodes).
* For lazy nodes, this is `null` or ùndefined` until the children are loaded
* and leaf nodes may be `[]` (empty array).
* @see {@link hasChildren}, {@link addChildren}, {@link lazy}.
*/
this.children = null;
/** Additional classes added to `div.wb-row`.
* @see {@link hasClass}, {@link setClass}. */
this.classes = null; //new Set<string>();
/** Custom data that was passed to the constructor */
this.data = {};
this._isLoading = false;
this._requestId = 0;
this._errorInfo = null;
this._partsel = false;
this._partload = false;
this.subMatchCount = 0;
this._rowIdx = 0;
this._rowElem = undefined;
assert(!parent || parent.tree === tree, `Invalid parent: ${parent}`);
assert(!data.children, "'children' not allowed here");
this.tree = tree;
this.parent = parent;
this.key = "" + ((_a = data.key) !== null && _a !== void 0 ? _a : ++WunderbaumNode.sequence);
this.title = "" + ((_b = data.title) !== null && _b !== void 0 ? _b : "<" + this.key + ">");
this.expanded = !!data.expanded;
this.lazy = !!data.lazy;
// We set the following node properties only if a matching data value is
// passed
data.refKey != null ? (this.refKey = "" + data.refKey) : 0;
data.type != null ? (this.type = "" + data.type) : 0;
data.icon != null ? (this.icon = intToBool(data.icon)) : 0;
data.tooltip != null ? (this.tooltip = intToBool(data.tooltip)) : 0;
data.iconTooltip != null
? (this.iconTooltip = intToBool(data.iconTooltip))
: 0;
data.statusNodeType != null
? (this.statusNodeType = ("" + data.statusNodeType))
: 0;
data.colspan != null ? (this.colspan = !!data.colspan) : 0;
// Selection
data.checkbox != null ? intToBool(data.checkbox) : 0;
data.radiogroup != null ? (this.radiogroup = !!data.radiogroup) : 0;
data.selected != null ? (this.selected = !!data.selected) : 0;
data.unselectable != null ? (this.unselectable = !!data.unselectable) : 0;
if (data.classes) {
this.setClass(data.classes);
}
// Store custom fields as `node.data`
for (const [key, value] of Object.entries(data)) {
if (!NODE_PROPS.has(key)) {
this.data[key] = value;
}
}
if (parent && !this.statusNodeType) {
// Don't register root node or status nodes
tree._registerNode(this);
}
}
/**
* Return readable string representation for this instance.
* @internal
*/
toString() {
return `WunderbaumNode@${this.key}<'${this.title}'>`;
}
/**
* Iterate all descendant nodes depth-first, pre-order using `for ... of ...` syntax.
* More concise, but slightly slower than {@link WunderbaumNode.visit}.
*
* Example:
* ```js
* for(const n of node) {
* ...
* }
* ```
*/
*[Symbol.iterator]() {
// let node: WunderbaumNode | null = this;
const cl = this.children;
if (cl) {
for (let i = 0, l = cl.length; i < l; i++) {
const n = cl[i];
yield n;
if (n.children) {
yield* n;
}
}
// Slower:
// for (let node of this.children) {
// yield node;
// yield* node : 0;
// }
}
}
// /** Return an option value. */
// protected _getOpt(
// name: string,
// nodeObject: any = null,
// treeOptions: any = null,
// defaultValue: any = null
// ): any {
// return evalOption(
// name,
// this,
// nodeObject || this,
// treeOptions || this.tree.options,
// defaultValue
// );
// }
/** Call event handler if defined in tree.options.
* Example:
* ```js
* node._callEvent("edit.beforeEdit", {foo: 42})
* ```
*/
_callEvent(type, extra) {
var _a;
return (_a = this.tree) === null || _a === void 0 ? void 0 : _a._callEvent(type, extend({
node: this,
typeInfo: this.type ? this.tree.types[this.type] : {},
}, extra));
}
/**
* Append (or insert) a list of child nodes.
*
* Tip: pass `{ before: 0 }` to prepend new nodes as first children.
*
* @returns first child added
*/
addChildren(nodeData, options) {
const tree = this.tree;
let { before = null, applyMinExpanLevel = true, _level } = options !== null && options !== void 0 ? options : {};
// let { before, loadLazy=true, _level } = options ?? {};
// const isTopCall = _level == null;
_level !== null && _level !== void 0 ? _level : (_level = this.getLevel());
const nodeList = [];
try {
tree.enableUpdate(false);
if (isPlainObject(nodeData)) {
nodeData = [nodeData];
}
const forceExpand = applyMinExpanLevel && _level < tree.options.minExpandLevel;
for (const child of nodeData) {
const subChildren = child.children;
delete child.children;
const n = new WunderbaumNode(tree, this, child);
if (forceExpand && !n.isUnloaded()) {
n.expanded = true;
}
nodeList.push(n);
if (subChildren) {
n.addChildren(subChildren, { _level: _level + 1 });
}
}
if (!this.children) {
this.children = nodeList;
}
else if (before == null || this.children.length === 0) {
this.children = this.children.concat(nodeList);
}
else {
// Returns null if before is not a direct child:
before = this.findDirectChild(before);
const pos = this.children.indexOf(before);
assert(pos >= 0, `options.before must be a direct child of ${this}`);
// insert nodeList after children[pos]
this.children.splice(pos, 0, ...nodeList);
}
// this.triggerModifyChild("add", nodeList.length === 1 ? nodeList[0] : null);
tree.update(ChangeType.structure);
}
finally {
// if (tree.options.selectMode === "hier") {
// if (this.parent && this.parent.children) {
// this.fixSelection3FromEndNodes();
// } else {
// // may happen when loading __root__;
// }
// }
tree.enableUpdate(true);
}
// if(isTopCall && loadLazy){
// this.logWarn("addChildren(): loadLazy is not yet implemented.")
// }
return nodeList[0];
}
/**
* Append or prepend a node, or append a child node.
*
* This a convenience function that calls addChildren()
*
* @param nodeData node definition
* @param [mode=child] 'before', 'after', 'firstChild', or 'child' ('over' is a synonym for 'child')
* @returns new node
*/
addNode(nodeData, mode = "appendChild") {
if (mode === "over") {
mode = "appendChild"; // compatible with drop region
}
switch (mode) {
case "after":
return this.parent.addChildren(nodeData, {
before: this.getNextSibling(),
});
case "before":
return this.parent.addChildren(nodeData, { before: this });
case "prependChild":
// Insert before the first child if any
// let insertBefore = this.children ? this.children[0] : undefined;
return this.addChildren(nodeData, { before: 0 });
case "appendChild":
return this.addChildren(nodeData);
}
assert(false, `Invalid mode: ${mode}`);
return undefined;
}
/**
* Apply a modification (or navigation) operation.
*
* @see {@link Wunderbaum.applyCommand}
*/
applyCommand(cmd, options) {
return this.tree.applyCommand(cmd, this, options);
}
/**
* Collapse all expanded sibling nodes if any.
* (Automatically called when `autoCollapse` is true.)
*/
collapseSiblings(options) {
for (const node of this.parent.children) {
if (node !== this && node.expanded) {
node.setExpanded(false, options);
}
}
}
/**
* Add/remove one or more classes to `<div class='wb-row'>`.
*
* This also maintains `node.classes`, so the class will survive a re-render.
*
* @param className one or more class names. Multiple classes can be passed
* as space-separated string, array of strings, or set of strings.
*/
setClass(className, flag = true) {
const cnSet = toSet(className);
if (flag) {
if (this.classes === null) {
this.classes = new Set();
}
cnSet.forEach((cn) => {
var _a;
this.classes.add(cn);
(_a = this._rowElem) === null || _a === void 0 ? void 0 : _a.classList.toggle(cn, flag);
});
}
else {
if (this.classes === null) {
return;
}
cnSet.forEach((cn) => {
var _a;
this.classes.delete(cn);
(_a = this._rowElem) === null || _a === void 0 ? void 0 : _a.classList.toggle(cn, flag);
});
if (this.classes.size === 0) {
this.classes = null;
}
}
}
/** Start editing this node's title. */
startEditTitle() {
this.tree._callMethod("edit.startEditTitle", this);
}
/**
* Call `setExpanded()` on all descendant nodes.
*
* @param flag true to expand, false to collapse.
* @param options Additional options.
* @see {@link Wunderbaum.expandAll}
* @see {@link WunderbaumNode.setExpanded}
*/
async expandAll(flag = true, options) {
const tree = this.tree;
const { collapseOthers, deep, depth, force, keepActiveNodeVisible = true, loadLazy, resetLazy, } = options !== null && options !== void 0 ? options : {};
// limit expansion level to `depth` (or tree.minExpandLevel). Default: unlimited
const treeLevel = this.tree.options.minExpandLevel || null; // 0 -> null
const minLevel = depth !== null && depth !== void 0 ? depth : (force ? null : treeLevel);
const expandOpts = {
deep: deep,
force: force,
loadLazy: loadLazy,
resetLazy: resetLazy,
scrollIntoView: false, // don't scroll every node while iterating
};
this.logInfo(`expandAll(${flag}, depth=${depth}, minLevel=${minLevel})`);
assert(!(flag && deep != null && !collapseOthers), "Expanding with `deep` option is not supported (implied by the `depth` option).");
// Expand all direct children in parallel:
async function _iter(n, level) {
var _a;
// n.logInfo(` _iter(level=${level})`);
const promises = [];
(_a = n.children) === null || _a === void 0 ? void 0 : _a.forEach((cn) => {
if (flag) {
if (!cn.expanded &&
(minLevel == null || level < minLevel) &&
(cn.children || (loadLazy && cn.lazy))) {
// Node is collapsed and may be expanded (i.e. has children or is lazy)
// Expanding may be async, so we store the promise.
// Also the recursion is delayed until expansion finished.
const p = cn.setExpanded(true, expandOpts);
promises.push(p);
if (depth == null) {
p.then(async () => {
await _iter(cn, level + 1);
});
}
}
else {
// We don't expand the node, but still visit descendants.
// There we may find lazy nodes, so we
promises.push(_iter(cn, level + 1));
}
}
else {
// Collapsing is always synchronous, so no promises required
// Do not collapse until minExpandLevel
if (minLevel == null || level >= minLevel) {
cn.setExpanded(false, expandOpts);
}
if ((minLevel != null && level < minLevel) || deep) {
_iter(cn, level + 1); // recursion, even if cn was already collapsed
}
}
});
return new Promise((resolve) => {
Promise.all(promises).then(() => {
resolve(true);
});
});
}
const tag = tree.logTime(`${this}.expandAll(${flag}, depth=${depth})`);
try {
tree.enableUpdate(false);
await _iter(this, 0);
if (collapseOthers) {
assert(flag, "Option `collapseOthers` requires flag=true");
assert(minLevel != null, "Option `collapseOthers` requires `depth` or `minExpandLevel`");
this.expandAll(false, { depth: minLevel });
}
}
finally {
tree.enableUpdate(true);
tree.logTimeEnd(tag);
}
if (tree.activeNode && keepActiveNodeVisible) {
tree.activeNode.scrollIntoView();
}
}
/**
* Find all descendant nodes that match condition (excluding self).
*
* If `match` is a string, search for exact node title.
* If `match` is a RegExp expression, apply it to node.title, using
* [RegExp.test()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test).
* If `match` is a callback, match all nodes for that the callback(node) returns true.
*
* Returns an empty array if no nodes were found.
*
* Examples:
* ```js
* // Match all node titles that match exactly 'Joe':
* nodeList = node.findAll("Joe")
* // Match all node titles that start with 'Joe' case sensitive:
* nodeList = node.findAll(/^Joe/)
* // Match all node titles that contain 'oe', case insensitive:
* nodeList = node.findAll(/oe/i)
* // Match all nodes with `data.price` >= 99:
* nodeList = node.findAll((n) => {
* return n.data.price >= 99;
* })
* ```
*/
findAll(match) {
const matcher = typeof match === "function" ? match : makeNodeTitleMatcher(match);
const res = [];
this.visit((n) => {
if (matcher(n)) {
res.push(n);
}
});
return res;
}
/** Return the direct child with a given key, index or null. */
findDirectChild(ptr) {
const cl = this.children;
if (!cl) {
return null;
}
if (typeof ptr === "string") {
for (let i = 0, l = cl.length; i < l; i++) {
if (cl[i].key === ptr) {
return cl[i];
}
}
}
else if (typeof ptr === "number") {
return cl[ptr];
}
else if (ptr.parent === this) {
// Return null if `ptr` is not a direct child
return ptr;
}
return null;
}
/**
* Find first descendant node that matches condition (excluding self) or null.
*
* @see {@link WunderbaumNode.findAll} for examples.
*/
findFirst(match) {
const matcher = typeof match === "function" ? match : makeNodeTitleMatcher(match);
let res = null;
this.visit((n) => {
if (matcher(n)) {
res = n;
return false;
}
});
return res;
}
/** Find a node relative to self.
*
* @see {@link Wunderbaum.findRelatedNode|tree.findRelatedNode()}
*/
findRelatedNode(where, includeHidden = false) {
return this.tree.findRelatedNode(this, where, includeHidden);
}
/**
* Iterator version of {@link WunderbaumNode.format}.
*/
*format_iter(name_cb, connectors) {
connectors !== null && connectors !== void 0 ? connectors : (connectors = [" ", " | ", " ╰─ ", " ├─ "]);
name_cb !== null && name_cb !== void 0 ? name_cb : (name_cb = (node) => "" + node);
function _is_last(node) {
const ca = node.parent.children;
return node === ca[ca.length - 1];
}
const _format_line = (node) => {
// https://www.measurethat.net/Benchmarks/Show/12196/0/arr-unshift-vs-push-reverse-small-array
const parts = [name_cb(node)];
parts.unshift(connectors[_is_last(node) ? 2 : 3]);
let p = node.parent;
while (p && p !== this) {
// `this` is the top node
parts.unshift(connectors[_is_last(p) ? 0 : 1]);
p = p.parent;
}
return parts.join("");
};
yield name_cb(this);
for (const node of this) {
yield _format_line(node);
}
}
/**
* Return a multiline string representation of a node/subnode hierarchy.
* Mostly useful for debugging.
*
* Example:
* ```js
* console.info(tree.getActiveNode().format((n)=>n.title));
* ```
* logs
* ```
* Books
* ├─ Art of War
* ╰─ Don Quixote
* ```
* @see {@link WunderbaumNode.format_iter}
*/
format(name_cb, connectors) {
const a = [];
for (const line of this.format_iter(name_cb, connectors)) {
a.push(line);
}
return a.join("\n");
}
/** Return the `<span class='wb-col'>` element with a given index or id.
* @returns {WunderbaumNode | null}
*/
getColElem(colIdx) {
var _a;
if (typeof colIdx === "string") {
colIdx = this.tree.columns.findIndex((value) => value.id === colIdx);
}
const colElems = (_a = this._rowElem) === null || _a === void 0 ? void 0 : _a.querySelectorAll("span.wb-col");
return colElems ? colElems[colIdx] : null;
}
/**
* Return all nodes with the same refKey.
*
* @param includeSelf Include this node itself.
* @see {@link Wunderbaum.findByRefKey}
*/
getCloneList(includeSelf = false) {
if (!this.refKey) {
return [];
}
const clones = this.tree.findByRefKey(this.refKey);
if (includeSelf) {
return clones;
}
return [...clones].filter((n) => n !== this);
}
/** Return the first child node or null.
* @returns {WunderbaumNode | null}
*/
getFirstChild() {
return this.children ? this.children[0] : null;
}
/** Return the last child node or null.
* @returns {WunderbaumNode | null}
*/
getLastChild() {
return this.children ? this.children[this.children.length - 1] : null;
}
/** Return node depth (starting with 1 for top level nodes). */
getLevel() {
let i = 0, p = this.parent;
while (p) {
i++;
p = p.parent;
}
return i;
}
/** Return the successive node (under the same parent) or null. */
getNextSibling() {
const ac = this.parent.children;
const idx = ac.indexOf(this);
return ac[idx + 1] || null;
}
/** Return the parent node (null for the system root node). */
getParent() {
// TODO: return null for top-level nodes?
return this.parent;
}
/** Return an array of all parent nodes (top-down).
* @param includeRoot Include the invisible system root node.
* @param includeSelf Include the node itself.
*/
getParentList(includeRoot = false, includeSelf = false) {
const l = [];
let dtn = includeSelf ? this : this.parent;
while (dtn) {
if (includeRoot || dtn.parent) {
l.unshift(dtn);
}
dtn = dtn.parent;
}
return l;
}
/** Return a string representing the hierachical node path, e.g. "a/b/c".
* @param includeSelf
* @param part property name or callback
* @param separator
*/
getPath(includeSelf = true, part = "title", separator = "/") {
// includeSelf = includeSelf !== false;
// part = part || "title";
// separator = separator || "/";
let val;
const path = [];
const isFunc = typeof part === "function";
this.visitParents((n) => {
if (n.parent) {
val = isFunc
? part(n)
: n[part];
path.unshift(val);
}
return undefined; // TODO remove this line
}, includeSelf);
return path.join(separator);
}
/** Return the preceeding node (under the same parent) or null. */
getPrevSibling() {
const ac = this.parent.children;
const idx = ac.indexOf(this);
return ac[idx - 1] || null;
}
/** Return true if node has children.
* Return undefined if not sure, i.e. the node is lazy and not yet loaded.
*/
hasChildren() {
if (this.lazy) {
if (this.children == null) {
return undefined; // null or undefined: Not yet loaded
}
else if (this.children.length === 0) {
return false; // Loaded, but response was empty
}
else if (this.children.length === 1 &&
this.children[0].isStatusNode()) {
return undefined; // Currently loading or load error
}
return true; // One or more child nodes
}
return !!(this.children && this.children.length);
}
/** Return true if node has className set. */
hasClass(className) {
return this.classes ? this.classes.has(className) : false;
}
/** Return true if node ist the currently focused node. @since 0.9.0 */
hasFocus() {
return this.tree.focusNode === this;
}
/** Return true if this node is the currently active tree node. */
isActive() {
return this.tree.activeNode === this;
}
/** Return true if this node is a direct or indirect parent of `other`.
* @see {@link WunderbaumNode.isParentOf}
*/
isAncestorOf(other) {
return other && other.isDescendantOf(this);
}
/** Return true if this node is a **direct** subnode of `other`.
* @see {@link WunderbaumNode.isDescendantOf}
*/
isChildOf(other) {
return other && this.parent === other;
}
/** Return true if this node's refKey is used by at least one other node.
*/
isClone() {
return !!this.refKey && this.tree.findByRefKey(this.refKey).length > 1;
}
/** Return true if this node's title spans all columns, i.e. the node has no
* grid cells.
*/
isColspan() {
return !!this.getOption("colspan");
}
/** Return true if this node is a direct or indirect subnode of `other`.
* @see {@link WunderbaumNode.isChildOf}
*/
isDescendantOf(other) {
if (!other || other.tree !== this.tree) {
return false;
}
let p = this.parent;
while (p) {
if (p === other) {
return true;
}
if (p === p.parent) {
error(`Recursive parent link: ${p}`);
}
p = p.parent;
}
return false;
}
/** Return true if this node has children, i.e. the node is generally expandable.
* If `andCollapsed` is set, we also check if this node is collapsed, i.e.
* an expand operation is currently possible.
*/
isExpandable(andCollapsed = false) {
// `false` is never expandable (unoffical)
if ((andCollapsed && this.expanded) || this.children === false) {
return false;
}
if (this.children == null) {
return !!this.lazy; // null or undefined can trigger lazy load
}
if (this.children.length === 0) {
return !!this.tree.options.emptyChildListExpandable;
}
return true;
}
/** Return true if _this_ node is currently in edit-title mode.
*
* See {@link WunderbaumNode.startEditTitle}.
*/
isEditingTitle() {
return this.tree._callMethod("edit.isEditingTitle", this);
}
/** Return true if this node is currently expanded. */
isExpanded() {
return !!this.expanded;
}
/** Return true if this node is the first node of its parent's children. */
isFirstSibling() {
const p = this.parent;
return !p || p.children[0] === this;
}
/** Return true if this node is the last node of its parent's children. */
isLastSibling() {
const p = this.parent;
return !p || p.children[p.children.length - 1] === this;
}
/** Return true if this node is lazy (even if data was already loaded) */
isLazy() {
return !!this.lazy;
}
/** Return true if node is lazy and loaded. For non-lazy nodes always return true. */
isLoaded() {
return !this.lazy || this.hasChildren() !== undefined; // Also checks if the only child is a status node
}
/** Return true if node is currently loading, i.e. a GET request is pending. */
isLoading() {
return this._isLoading;
}
/** Return true if this node is a temporarily generated status node of type 'paging'. */
isPagingNode() {
return this.statusNodeType === "paging";
}
/** Return true if this node is a **direct** parent of `other`.
* @see {@link WunderbaumNode.isAncestorOf}
*/
isParentOf(other) {
return other && other.parent === this;
}
/** Return true if this node is partially loaded. @experimental */
isPartload() {
return !!this._partload;
}
/** Return true if this node is partially selected (tri-state). */
isPartsel() {
return !this.selected && !!this._partsel;
}
/** Return true if this node has DOM representaion, i.e. is displayed in the viewport. */
isRadio() {
return !!this.parent.radiogroup || this.getOption("checkbox") === "radio";
}
/** Return true if this node has DOM representaion, i.e. is displayed in the viewport. */
isRendered() {
return !!this._rowElem;
}
/** Return true if this node is the (invisible) system root node.
* @see {@link WunderbaumNode.isTopLevel}
*/
isRootNode() {
return this.tree.root === this;
}
/** Return true if this node is selected, i.e. the checkbox is set.
* `undefined` if partly selected (tri-state), false otherwise.
*/
isSelected() {
return this.selected ? true : this._partsel ? undefined : false;
}
/** Return true if this node is a temporarily generated system node like
* 'loading', 'paging', or 'error' (node.statusNodeType contains the type).
*/
isStatusNode() {
return !!this.statusNodeType;
}
/** Return true if this a top level node, i.e. a direct child of the (invisible) system root node. */
isTopLevel() {
return this.tree.root === this.parent;
}
/** Return true if node is marked lazy but not yet loaded.
* For non-lazy nodes always return false.
*/
isUnloaded() {
// Also checks if the only child is a status node:
return this.hasChildren() === undefined;
}
/** Return true if all parent nodes are expanded. Note: this does not check
* whether the node is scrolled into the visible part of the screen or viewport.
*/
isVisible() {
const hasFilter = this.tree.filterMode === "hide";
const parents = this.getParentList(false, false);
// TODO: check $(n.span).is(":visible")
// i.e. return false for nodes (but not parents) that are hidden
// by a filter
if (hasFilter && !this.match && !this.subMatchCount) {
// this.debug( "isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")" );
return false;
}
for (let i = 0, l = parents.length; i < l; i++) {
const n = parents[i];
if (!n.expanded) {
// this.debug("isVisible: HIDDEN (parent collapsed)");
return false;
}
// if (hasFilter && !n.match && !n.subMatchCount) {
// this.debug("isVisible: HIDDEN (" + hasFilter + ", " + this.match + ", " + this.match + ")");
// return false;
// }
}
// this.debug("isVisible: VISIBLE");
return true;
}
_loadSourceObject(source, level) {
var _a;
const tree = this.tree;
level !== null && level !== void 0 ? level : (level = this.getLevel());
// Let caller modify the parsed JSON response:
const res = this._callEvent("receive", { response: source });
if (res != null) {
source = res;
}
if (isArray(source)) {
source = { children: source };
}
assert(isPlainObject(source), `Expected an array or plain object: ${source}`);
const format = (_a = source.format) !== null && _a !== void 0 ? _a : "nested";
assert(format === "nested" || format === "flat", `Expected source.format = 'nested' or 'flat': ${format}`);
// Pre-rocess for 'nested' or 'flat' format
decompressSourceData(source);
assert(source.children, "If `source` is an object, it must have a `children` property");
if (source.types) {
tree.logInfo("Redefine types", source.columns);
tree.setTypes(source.types, false);
delete source.types;
}
if (source.columns) {
tree.logInfo("Redefine columns", source.columns);
tree.columns = source.columns;
delete source.columns;
tree.update(ChangeType.colStructure);
}
this.addChildren(source.children);
// Add extra data to `tree.data`
for (const [key, value] of Object.entries(source)) {
if (!RESERVED_TREE_SOURCE_KEYS.has(key)) {
tree.data[key] = value;
// tree.logDebug(`Add source.${key} to tree.data.${key}`);
}
}
if (tree.options.selectMode === "hier") {
this.fixSelection3FromEndNodes();
}
// Allow to un-sort nodes after sorting
this.resetNativeChildOrder();
this._callEvent("load");
}
async _fetchWithOptions(source) {
var _a, _b;
// Either a URL string or an object with a `.url` property.
let url, params, body, options, rest;
let fetchOpts = {};
if (typeof source === "string") {
// source is a plain URL string: assume GET request
url = source;
fetchOpts.method = "GET";
}
else if (isPlainObject(source)) {
// source is a plain object with `.url` property.
({ url, params, body, options, ...rest } = source);
assert(!rest || Object.keys(rest).length === 0, `Unexpected source properties: ${Object.keys(rest)}. Use 'options' instead.`);
assert(typeof url === "string", `expected source.url as string`);
if (isPlainObject(options)) {
fetchOpts = options;
}
if (isPlainObject(body)) {
// we also accept 'body' as object...
assert(!fetchOpts.body, "options.body should be passed as source.body");
fetchOpts.body = JSON.stringify(fetchOpts.body);
(_a = fetchOpts.method) !== null && _a !== void 0 ? _a : (fetchOpts.method = "POST"); // set default
}
if (isPlainObject(params)) {
url += "?" + new URLSearchParams(params);
(_b = fetchOpts.method) !== null && _b !== void 0 ? _b : (fetchOpts.method = "GET"); // set default
}
}
else {
url = ""; // keep linter happy
error(`Unsupported source format: ${source}`);
}
this.setStatus(NodeStatusType.loading);
const response = await fetch(url, fetchOpts);
if (!response.ok) {
error(`GET ${url} returned ${response.status}, ${response}`);
}
return await response.json();
}
/** Download data from the cloud, then call `.update()`. */
async load(source) {
const tree = this.tree;
const requestId = Date.now();
const prevParent = this.parent;
const start = Date.now();
let elap = 0, elapLoad = 0, elapProcess = 0;
// Check for overlapping requests
if (this._requestId) {
this.logWarn(`Recursive load request #${requestId} while #${this._requestId} is pending. ` +
"The previous request will be ignored.");
}
this._requestId = requestId;
// const timerLabel = tree.logTime(this + ".load()");
try {
const url = typeof source === "string" ? source : source.url;
if (!url) {
// An array or a plain object (that does NOT contain a `.url` property)
// will be treated as native Wunderbaum data
if (typeof source.then === "function") {
const msg = tree.logTime(`Resolve thenable ${source}`);
source = await Promise.resolve(source);
tree.logTimeEnd(msg);
}
this._loadSourceObject(source);
elapProcess = Date.now() - start;
}
else {
// Either a URL string or an object with a `.url` property.
const data = await this._fetchWithOptions(source);
elapLoad = Date.now() - start;
if (this._requestId && this._requestId > requestId) {
this.logWarn(`Ignored load response #${requestId} because #${this._requestId} is pending.`);
return;
}
else {
this.logDebug(`Received response for load request #${requestId}`);
}
if (this.parent === null && prevParent !== null) {
this.logWarn("Lazy parent node was removed while loading: discarding response.");
return;
}
this.setStatus(NodeStatusType.ok);
// if (data.columns) {
// tree.logInfo("Re-define columns", data.columns);
// util.assert(!this.parent);
// tree.columns = data.columns;
// delete data.columns;
// tree.updateColumns({ calculateCols: false });
// }
const startProcess = Date.now();
this._loadSourceObject(data);
elapProcess = Date.now() - startProcess;
}
}
catch (error) {
this.logError("Error during load()", source, error);
this._callEvent("error", { error: error });
this.setStatus(NodeStatusType.error, { message: "" + error });
throw error;
}
finally {
this._requestId = 0;
elap = Date.now() - start;
if (tree.options.debugLevel >= 3) {
tree.logInfo(`Load source took ${elap / 1000} seconds ` +
`(transfer: ${elapLoad / 1000}s, ` +
`processing: ${elapProcess / 1000}s)`);
}
}
}
/**
* Load content of a lazy node.
* If the node is already loaded, nothing happens.
* @param [forceReload=false] If true, reload even if already loaded.
*/
async loadLazy(forceReload = false) {
const wasExpanded = this.expanded;
assert(this.lazy, "load() requires a lazy node");
if (!forceReload && !this.isUnloaded()) {
return; // Already loaded: nothing to do
}
if (this.isLoading()) {
this.logWarn("loadLazy() called while already loading: ignored.");
return; // Already loading: prevent duplicate requests
}
if (this.isLoaded()) {
this.resetLazy(); // Also collapses if currently expanded
}
// `lazyLoad` may be long-running, so mark node as loading now. `this.load()`
// will reset the status later.
this.setStatus(NodeStatusType.loading);
try {
const source = await this._callEvent("lazyLoad");
if (source === false) {
this.setStatus(NodeStatusType.ok);
return;
}
assert(isArray(source) || (source && source.url), "The lazyLoad event must return a node list, `{url: ...}`, or false.");
await this.load(source);
this.setStatus(NodeStatusType.ok); // Also resets `this._isLoading`
if (wasExpanded) {
this.expanded = true;
this.tree.update(ChangeType.structure);
}
else {
this.update(); // Fix expander icon to 'loaded'
}
}
catch (e) {
this.logError("Error during loadLazy()", e);
this._callEvent("error", { error: e });
// Also resets `this._isLoading`:
this.setStatus(NodeStatusType.error, { message: "" + e });
}
return;
}
/** Write to `console.log` with node name as prefix if opts.debugLevel >= 4.
* @see {@link WunderbaumNode.logDebug}
*/
log(...args) {
if (this.tree.options.debugLevel >= 4) {
console.log(this.toString(), ...args); // eslint-disable-line no-console
}
}
/** Write to `console.debug` with node name as prefix if opts.debugLevel >= 4
* and browser console level includes debug/verbose messages.
* @see {@link WunderbaumNode.log}
*/
logDebug(...args) {
if (this.tree.options.debugLevel >= 4) {
console.debug(this.toString(), ...args); // eslint-disable-line no-console
}
}
/** Write to `console.error` with node name as prefix if opts.debugLevel >= 1. */
logError(...args) {
if (this.tree.options.debugLevel >= 1) {
console.error(this.toString(), ...args); // eslint-disable-line no-console
}
}
/** Write to `console.info` with node name as prefix if opts.debugLevel >= 3. */
logInfo(...args) {
if (this.tree.options.debugLevel >= 3) {
console.info(this.toString(), ...args); // eslint-disable-line no-console
}
}
/** Write to `console.warn` with node name as prefix if opts.debugLevel >= 2. */
logWarn(...args) {
if (this.tree.options.debugLevel >= 2) {
console.warn(this.toString(), ...args); // eslint-disable-line no-console
}
}
/** Expand all parents and optionally scroll into visible area as neccessary.
* Promise is resolved, when lazy loading and animations are done.
* @param {object} [options] passed to `setExpanded()`.
* Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true}
*/
async makeVisible(options) {
let i;
const dfd = new Deferred();
const deferreds = [];
const parents = this.getParentList(false, false);
const len = parents.length;
const noAnimation = getOption(options, "noAnimation", false);
const scroll = getOption(options, "scrollIntoView", true);
// Expand bottom-up, so only the top node is animated
for (i = len - 1; i >= 0; i--) {
// self.debug("pushexpand" + parents[i]);
const seOpts = { noAnimation: noAnimation };
deferreds.push(parents[i].setExpanded(true, seOpts));
}
Promise.all(deferreds).then(() => {
// All expands have finished
// self.debug("expand DONE", scroll);
// Note: this.tree may be none when switching demo trees
if (scroll && this.tree) {
// Make sure markup and _rowIdx is updated before we do the scroll calculations
this.tree.updatePendingModifications();
this.scrollIntoView().then(() => {
// self.debug("scroll DONE");
dfd.resolve();
});
}
else {
dfd.resolve();
}
});
return dfd.promise();
}
/** Move this node to targetNode. */
moveTo(targetNode, mode = "appendChild", map) {
if (mode === "over") {
mode = "appendChild"; // compatible with drop region
}
if (mode === "prependChild") {
if (targetNode.children && targetNode.children.length) {
mode = "before";
targetNode = targetNode.children[0];
}
else {
mode = "appendChild";
}
}
let pos;
const tree = this.tree;
const prevParent = this.parent;
const targetParent = mode === "appendChild" ? targetNode : targetNode.parent;
if (this === targetNode) {
return;
}
else if (!this.parent) {
error("Cannot move system root");
}
else if (targetParent.isDescendantOf(this)) {
error("Cannot move a node to its own descendant");
}
if (targetParent !== prevParent) {
prevParent.triggerModifyChild("remove", this);
}
// Unlink this node from current parent
if (this.parent.children.length === 1) {
if (this.parent === targetParent) {
return; // #258
}
this.parent.children = this.parent.lazy ? [] : null;
this.parent.expanded = false;
}
else {
pos = this.parent.children.indexOf(this);
assert(pos >= 0, "invalid source parent");
this.parent.children.splice(pos, 1);
}
// Insert this node to target parent's child list
this.parent = targetParent;
if (targetParent.hasChildren()) {
switch (mode) {
case "appendChild":
// Append to existing target children
targetParent.children.push(this);
break;
case "before":
// Insert this node before target node
pos = targetParent.children.indexOf(targetNode);
assert(pos >= 0, "invalid target parent");
targetParent.children.splice(pos, 0, this);
break;
case "after":
// Insert this node after target node
pos = targetParent.children.indexOf(targetNode);
assert(pos >= 0, "invalid target parent");
targetParent.children.splice(pos + 1, 0, this);
break;
default:
error(`Invalid mode '${mode}'.`);
}
}
else {
targetParent.children = [this];
}
// Let caller modify the nodes
if (map) {
targetNode.visit(map, true);
}
if (targetParent === prevParent) {
targetParent.triggerModifyChild("move", this);
}
else {
// prevParent.triggerModifyChild("remove", this);
targetParent.triggerModifyChild("add", this);
}
// Handle cross-tree moves
if (tree !== targetNode.tree) {
// Fix node.tree for all source nodes
// util.assert(false, "Cross-tree move is not yet implemented.");
this.logWarn("Cross-tree moveTo is experimental!");
this.visit((n) => {
// TODO: fix selection state and activation, ...
n.tree = targetNode.tree;
}, true);
}
// Make sure we update async, because discarding the markup would prevent
// DragAndDrop to generate a dragend event on the source node
setTimeout(() => {
// Even indentation may have changed:
tree.update(ChangeType.any);
}, 0);
// TODO: fix selection state
// TODO: fix active state
}
/** Set focus relative to this node and optionally activate.
*
* 'left' collapses the node if it is expanded, or move to the parent
* otherwise.
* 'right' expands the node if it is collapsed, or move to the first
* child otherwise.
*
* @param where 'down', 'first', 'last', 'left', 'parent', 'right', or 'up'.
* (Alternatively the `event.key` that would normally trigger this move,
* e.g. `ArrowLeft` = 'left'.
* @param options
*/
async navigate(where, options) {
var _a;
// Allow to pass 'ArrowLeft' instead of 'left'
const navType = ((_a = KEY_TO_NAVIGATION_MAP[where]) !== null && _a !== void 0 ? _a : where);
// Otherwise activate or focus the related node
const node = this.findRelatedNode(navType);
if (!node) {
this.logWarn(`Could not find related node '${where}'.`);
return Promise.resolve(this);
}
// setFocus/setActive will scroll later (if autoScroll is specified)
try {
node.makeVisible({ scrollIntoView: false });
}
catch (e) {
// ignore
}
node.setFocus();
if ((options === null || options === void 0 ? void 0 : options.activate) === false) {
return Promise.resolve(this);
}
return node.setActive(true, { event: options === null || options === void 0 ? void 0 : options.event });
}
/** Delete this node and all descendants. */
remove() {
const tree = this.tree;
const pos = this.parent.children.indexOf(this);
this.triggerModify("remove");
this.parent.children.splice(pos, 1);
this.visit((n) => {
n.removeMarkup();
tree._unregisterNode(n);
}, true);
tree.update(ChangeType.structure);
}
/** Remove all descendants of this node. */
removeChildren() {
var _a, _b;
const tree = this.tree;
if (!this.children) {
return;
}
if ((_a = tree.activeNode) === null || _a === void 0 ? void 0 : _a.isDescendantOf(this)) {
tree.activeNode.setActive(false); // TODO: don't fire events
}
if ((_b = tree.focusNode) === null || _b === void 0 ? void 0 : _b.isDescendantOf(this)) {
tree._setFocusNode(null);
}
// TODO: persist must take care to clear select and expand cookies
// Unlink children to support GC
// TODO: also delete this.children (not possible using visit())
this.triggerModifyChild("remove", null);
this.visit((n) => {
tree._unregisterNode(n);
});
if (this.lazy) {
// 'undefined' would be interpreted as 'not yet loaded' for lazy nodes
this.children = [];
}
else {
this.children = null;
}
// util.assert(this.parent); // don't call this for root node
if (!this.isRootNode()) {
this.expanded = false;
}
this.tree.update(ChangeType.structure);
}
/** Remove all HTML markup from the DOM. */
removeMarkup() {
if (this._rowElem) {
delete this._rowElem._wb_node;
this._rowElem.remove();
this._rowElem = undefined;
}
}
_getRenderInfo() {
const allColInfosById = {};
const renderColInfosById = {};
const isColspan = this.isColspan();
const colElems = this._rowElem
? (this._rowElem.querySelectorAll("span.wb-col"))
: null;
let idx = 0;
for (const col of this.tree.columns) {
allColInfosById[col.id] = {
id: col.id,
idx: idx,
elem: colElems ? colElems[idx] : null,
info: col,
};
// renderColInfosById only contains columns that need rendering:
if (!isColspan && col.id !== "*") {
renderColInfosById[col.id] = allColInfosById[col.id];
}
idx++;
}
return {
allColInfosById: allColInfosById,
renderColInfosById: renderColInfosById,
};
}
_createIcon(parentElem, replaceChild, showLoading) {
const iconElem = this.tree._createNodeIcon(this, showLoading, true);
if (iconElem) {
if (replaceChild) {
parentElem.replaceChild(iconElem, replaceChild);
}
else {
parentElem.appendChild(iconElem);
}
}
return iconElem;
}
/**
* Create a whole new `<div class="wb-row">` element.
* @see {@link WunderbaumNode._render}
*/
_render_markup(opts) {
const tree = this.tree;
const treeOptions = tree.options;
const rowHeight = treeOptions.rowHeightPx;
const checkbox = this.getOption("checkbox");
const columns = tree.columns;
const level = this.getLevel();
const activeColIdx = tree.isRowNav() ? null : tree.activeColIdx;
let elem;
let rowDiv = this._rowElem;
let checkboxSpan = null;
let expanderSpan = null;
const isNew = !rowDiv;
assert(isNew, "Expected unrendered node");
assert(!isNew || (opts && opts.after), "opts.after expected, unless updating");
assert(!this.isRootNode(), "Root node not allowed");
rowDiv = document.createElement("div");
rowDiv.classList.add("wb-row");
rowDiv.style.top = this._rowIdx * rowHeight + "px";
this._rowElem = rowDiv;
// Attach a node reference to the DOM Element:
rowDiv._wb_node = this;
const nodeElem = document.createElement("span");
nodeElem.classList.add("wb-node", "wb-col");
rowDiv.appendChild(nodeElem);
let ofsTitlePx = 0;
if (checkbox) {
checkboxSpan = document.createElement("i");
checkboxSpan.classList.add("wb-checkbox");
if (checkbox === "radio" || this.parent.radiogroup) {
checkboxSpan.classList.add("wb-radio");
}
nodeElem.appendChild(checkboxSpan);
ofsTitlePx += ICON_WIDTH;
}
for (let i = level - 1; i > 0; i--) {
elem = document.createElement("i");
elem.classList.add("wb-indent");
nodeElem.appendChild(elem);
ofsTitlePx += ICON_WIDTH;
}
if (!treeOptions.minExpandLevel || level > treeOptions.minExpandLevel) {
expanderSpan = document.createElement("i");
expanderSpan.classList.add("wb-expander");
nodeElem.appendChild(expanderSpan);
ofsTitlePx += ICON_WIDTH;
}
// Render the icon (show a 'loading' icon if we do not have an expander that
// we would prefer).
const iconSpan = this._createIcon(nodeElem, null, !expanderSpan);
if (iconSpan) {
ofsTitlePx += ICON_WIDTH;
}
const titleSpan = document.createElement("span");
titleSpan.classList.add("wb-title");
nodeElem.appendChild(titleSpan);
// this._callEvent("enhanceTitle", { titleSpan: titleSpan });
// Store the width of leading icons with the node, so we can calculate
// the width of the embedded title span later
nodeElem._ofsTitlePx = ofsTitlePx;
// Support HTML5 drag-n-drop
if (tree.options.dnd.dragStart) {
nodeElem.draggable = true;
}
// Render columns
const isColspan = this.isColspan();
if (!isColspan && columns.length > 1) {
let colIdx = 0;
for (const col of columns) {
colIdx++;
let colElem;
if (col.id === "*") {
colElem = nodeElem;
}
else {
colElem = document.createElement("span");
colElem.classList.add("wb-col");
rowDiv.appendChild(colElem);
}
if (colIdx === activeColIdx) {
colElem.classList.add("wb-active");
}
// Add classes from `columns` definition to `<div.wb-col>` cells
col.classes ? colElem.classList.add(...col.classes.split(" ")) : 0;
colElem.style.left = col._ofsPx + "px";
colElem.style.width = col._widthPx + "px";
if (isNew && col.html) {
if (typeof col.html === "string") {
colElem.innerHTML = col.html;
}
}
}
}
// Attach to DOM as late as possible
const after = opts ? opts.after : "last";
switch (after) {
case "first":
tree.nodeListElement.prepend(rowDiv);
break;
case "last":
tree.nodeListElement.appendChild(rowDiv);
break;
default:
opts.after.after(rowDiv);
}
// Now go on and fill in data and update classes
opts.isNew = true;
this._render_data(opts);
}
/**
* Render `node.title`, `.icon` into an existing row.
*
* @see {@link WunderbaumNode._render}
*/
_render_data(opts) {
assert(this._rowElem, "No _rowElem");
const tree = this.tree;
const treeOptions = tree.options;
const rowDiv = this._rowElem;
const isNew = !!opts.isNew; // Called by _render_markup()?
const preventScroll = !!opts.preventScroll;
const columns = tree.columns;
const isColspan = this.isColspan();
// Row markup already exists
const nodeElem = rowDiv.querySelector("span.wb-node");
const titleSpan = nodeElem.querySelector("span.wb-title");
const scrollTop = tree.element.scrollTop;
if (this.titleWithHighlight) {
titleSpan.innerHTML = this.titleWithHighlight;
}
else {
titleSpan.textContent = this.title; // TODO: this triggers scroll events
}
const tooltip = this.getOption("tooltip", false);
if (tooltip) {
titleSpan.title = tooltip === true ? this.title : tooltip;
}
// NOTE: At least on Safari, this render call triggers a scroll event
// probably when a focused input is replaced.
if (preventScroll) {
tree.element.scrollTop = scrollTop;
}
// Set the width of the title span, so overflow ellipsis work
if (!treeOptions.skeleton) {
if (isColspan) {
const vpWidth = tree.element.clientWidth;
titleSpan.style.width =
vpWidth - nodeElem._ofsTitlePx - TITLE_SPAN_PAD_Y + "px";
}
else {
titleSpan.style.width =
columns[0]._widthPx -
nodeElem._ofsTitlePx -
TITLE_SPAN_PAD_Y +
"px";
}
}
// Update row classes
opts.isDataChange = true;
this._render_status(opts);
// Let user modify the result
if (this.statusNodeType) {
this._callEvent("renderStatusNode", {
isNew: isNew,
nodeElem: nodeElem,
isColspan: isColspan,
});
}
else if (this.parent) {
// Skip root node
const renderInfo = this._getRenderInfo();
this._callEvent("render", {
isNew: isNew,
nodeElem: nodeElem,
isColspan: isColspan,
allColInfosById: renderInfo.allColInfosById,
renderColInfosById: renderInfo.renderColInfosById,
});
}
}
/**
* Update row classes to reflect active, focuses, etc.
* @see {@link WunderbaumNode._render}
*/
_render_status(opts) {
// this.log("_render_status", opts);
const tree = this.tree;
const iconMap = tree.iconMap;
const treeOptions = tree.options;
const typeInfo = this.type ? tree.types[this.type] : null;
const rowDiv = this._rowElem;
// Row markup already exists
const nodeElem = rowDiv.querySelector("span.wb-node");
const expanderSpan = nodeElem.querySelector("i.wb-expander");
const checkboxSpan = nodeElem.querySelector("i.wb-checkbox");
const rowClasses = ["wb-row"];
this.expanded ? rowClasses.push("wb-expanded") : 0;
this.lazy ? rowClasses.push("wb-lazy") : 0;
this.selected ? rowClasses.push("wb-selected") : 0;
this._partsel ? rowClasses.push("wb-partsel") : 0;
this === tree.activeNode ? rowClasses.push("wb-active") : 0;
this === tree.focusNode ? rowClasses.push("wb-focus") : 0;
this._errorInfo ? rowClasses.push("wb-error") : 0;
this._isLoading ? rowClasses.push("wb-loading") : 0;
this.isColspan() ? rowClasses.push("wb-colspan") : 0;
this.statusNodeType
? rowClasses.push("wb-status-" + this.statusNodeType)
: 0;
this.match ? rowClasses.push("wb-match") : 0;
this.subMatchCount ? rowClasses.push("wb-submatch") : 0;
treeOptions.skeleton ? rowClasses.push("wb-skeleton") : 0;
// Replace previous classes:
rowDiv.className = rowClasses.join(" ");
// Add classes from `node.classes`
this.classes ? rowDiv.classList.add(...this.classes) : 0;
// Add classes from `tree.types[node.type]`
if (typeInfo && typeInfo.classes) {
rowDiv.classList.add(...typeInfo.classes);
}
if (expanderSpan) {
let image = null;
if (this._isLoading) {
image = iconMap.loading;
}
else if (this.isExpandable(false)) {
if (this.expanded) {
image = iconMap.expanderExpanded;
}
else {
image = iconMap.expanderCollapsed;
}
}
else if (this.lazy && this.children == null) {
image = iconMap.expanderLazy;
}
if (image == null) {
expanderSpan.classList.add("wb-indent");
}
else if (TEST_IMG.test(image)) {
expanderSpan.style.backgroundImage = `url('${image}')`;
}
else {
expanderSpan.className = "wb-expander " + image;
}
}
if (checkboxSpan) {
let cbclass = "wb-checkbox ";
if (this.isRadio()) {
cbclass += "wb-radio ";
if (this.selected) {
cbclass += iconMap.radioChecked;
// } else if (this._partsel) {
// cbclass += iconMap.radioUnknown;
}
else {
cbclass += iconMap.radioUnchecked;
}
}
else {
if (this.selected) {
cbclass += iconMap.checkChecked;
}
else if (this._partsel) {
cbclass += iconMap.checkUnknown;
}
else {
cbclass += iconMap.checkUnchecked;
}
}
checkboxSpan.className = cbclass;
}
// Fix active cell in cell-nav mode
if (!opts.isNew) {
let i = 0;
for (const colSpan of rowDiv.children) {
colSpan.classList.toggle("wb-active", i++ === tree.activeColIdx);
colSpan.classList.remove("wb-error", "wb-invalid");
}
// Update icon (if not opts.isNew, which would rebuild markup anyway)
const iconSpan = nodeElem.querySelector("i.wb-icon");
if (iconSpan) {
this._createIcon(nodeElem, iconSpan, !expanderSpan);
}
}
// Adjust column width
if (opts.resizeCols !== false && !this.isColspan()) {
const colElems = rowDiv.querySelectorAll("span.wb-col");
let idx = 0;
let ofs = 0;
for (const colDef of this.tree.columns) {
const colElem = colElems[idx];
colElem.style.left = `${ofs}px`;
colElem.style.width = `${colDef._widthPx}px`;
idx++;
ofs += colDef._widthPx;
}
}
}
/*
* Create or update node's markup.
*
* `options.change` defaults to ChangeType.data, which updates the title,
* icon, and status. It also triggers the `render` event, that lets the user
* create or update the content of embeded cell elements.
*
* If only the status or other class-only modifications have changed,
* `options.change` should be set to ChangeType.status instead for best
* efficiency.
*
* Calling `update()` is almost always a better alternative.
* @see {@link WunderbaumNode.update}
*/
_render(options) {
// this.log("render", options);
const opts = Object.assign({ change: ChangeType.data }, options);
if (!this._rowElem) {
opts.change = ChangeType.row;
}
switch (opts.change) {
case "status":
this._render_status(opts);
break;
case "data":
this._render_data(opts);
break;
case "row":
// _rowElem is not yet created (asserted in _render_markup)
this._render_markup(opts);
break;
default:
error(`Invalid change type '${opts.change}'.`);
}
}
/**
* Remove all children, collapse, and set the lazy-flag, so that the lazyLoad
* event is triggered on next expand.
*/
resetLazy() {
this.removeChildren();
this.expanded = false;
this.lazy = true;
this.children = null;
this.tree.update(ChangeType.structure);
}
/** Convert node (or whole branch) into a plain object.
*
* The result is compatible with node.addChildren().
*
* @param recursive include child nodes
* @param callback is called for every node, in order to allow
* modifications.
* Return `false` to ignore this node or `"skip"` to include this node
* without its children.
* @see {@link Wunderbaum.toDictArray}.
*/
toDict(recursive = false, callback) {
const dict = {};
NODE_DICT_PROPS.forEach((propName) => {
const val = this[propName];
if (val instanceof Set) {
// Convert Set to string (or skip if set is empty)
val.size
? (dict[propName] = Array.prototype.join.call(val.keys(), " "))
: 0;
}
else if (val || val === false || val === 0) {
dict[propName] = val;
}
});
if (!isEmptyObject(this.data)) {
dict.data = extend({}, this.data);
if (isEmptyObject(dict.data)) {
delete dict.data;
}
}
if (callback) {
const res = callback(dict, this);
if (res === false) {
// Note: a return value of `false` is only used internally
return false; // Don't include this node nor its children
}
if (res === "skip") {
recursive = false; // Include this node, but not the children
}
}
if (recursive) {
if (isArray(this.children)) {
dict.children = [];
for (let i = 0, l = this.children.length; i < l; i++) {
const node = this.children[i];
if (!node.isStatusNode()) {
// Note: a return value of `false` is only used internally
const res = node.toDict(true, callback);
if (res !== false) {
dict.children.push(res);
}
}
}
}
}
return dict;
}
/** Return an option value that has a default, but may be overridden by a
* callback or a node instance attribute.
*
* Evaluation sequence:
*
* - If `tree.options.<name>` is a callback that returns something, use that.
* - Else if `node.<name>` is defined, use that.
* - Else if `tree.types[<node.type>]` is a value, use that.
* - Else if `tree.options.<name>` is a value, use that.
* - Else use `defaultValue`.
*
* @param name name of the option property (on node and tree)
* @param defaultValue return this if nothing else matched
* {@link Wunderbaum.getOption|Wunderbaum.getOption}
*/
getOption(name, defaultValue) {
const tree = this.tree;
let opts = tree.options;
// Lookup `name` in options dict
if (name.indexOf(".") >= 0) {
[opts, name] = name.split(".");
}
const value = opts[name]; // ?? defaultValue;
// A callback resolver always takes precedence
if (typeof value === "function") {
const res = value.call(tree, {
type: "resolve",
tree: tree,
node: this,
// typeInfo: this.type ? tree.types[this.type] : {},
});
if (res !== undefined) {
return res;
}
}
// If this node has an explicit local setting, use it:
if (this[name] !== undefined) {
return this[name];
}
// Use value from type definition if defined
const typeInfo = this.type ? tree.types[this.type] : undefined;
const res = typeInfo ? typeInfo[name] : undefined;
if (res !== undefined) {
return res;
}
// Use value from value options dict, fallback do default
return value !== null && value !== void 0 ? value : defaultValue;
}
/** Make sure that this node is visible in the viewport.
* @see {@link Wunderbaum.scrollTo|Wunderbaum.scrollTo}
*/
async scrollIntoView(options) {
const opts = Object.assign({ node: this }, options);
return this.tree.scrollTo(opts);
}
/**
* Activate this node, deactivate previous, send events, activate column and
* scroll into viewport.
*/
async setActive(flag = true, options) {
const tree = this.tree;
const prev = tree.getActiveNode();
const retrigger = options === null || options === void 0 ? void 0 : options.retrigger; // Default: false
const focusTree = options === null || options === void 0 ? void 0 : options.focusTree; // Default: false
// const focusNode = options?.focusNode !== false; // Default: true
const noEvents = options === null || options === void 0 ? void 0 : options.noEvents; // Default: false
const orgEvent = options === null || options === void 0 ? void 0 : options.event; // Default: null
const colIdx = options === null || options === void 0 ? void 0 : options.colIdx; // Default: null
const edit = options === null || options === void 0 ? void 0 : options.edit; // Default: false
// util.assert(!colIdx || tree.isCellNav(), "colIdx requires cellNav");
assert(!edit || colIdx != null, "edit requires colIdx");
if (!noEvents) {
if (flag) {
if (prev !== this || retrigger) {
if ((prev === null || prev === void 0 ? void 0 : prev._callEvent("deactivate", {
nextNode: this,
event: orgEvent,
})) === false ||
this._callEvent("beforeActivate", {
prevNode: prev,
event: orgEvent,
}) === false) {
return;
}
tree._setActiveNode(null);
prev === null || prev === void 0 ? void 0 : prev.update(ChangeType.status);
}
}
else if (prev === this || retrigger) {
this._callEvent("deactivate", { nextNode: null, event: orgEvent });
}
}
if (prev !== this) {
if (flag) {
tree._setActiveNode(this);
}
prev === null || prev === void 0 ? void 0 : prev.update(ChangeType.status);
this.update(ChangeType.status);
}
return this.makeVisible().then(() => {
if (flag) {
if (focusTree || edit) {
tree.setFocus();
tree._setFocusNode(this);
tree.focusNode.setFocus();
}
// if (focusNode || edit) {
// tree.focusNode = this;
// tree.focusNode.setFocus();
// }
if (colIdx != null && tree.isCellNav()) {
tree.setColumn(colIdx, { edit: edit });
}
if (!noEvents) {
this._callEvent("activate", { prevNode: prev, event: orgEvent });
}
}
});
}
/**
* Expand or collapse this node.
*/
async setExpanded(flag = true, options) {
const { force, scrollIntoView, immediate, resetLazy } = options !== null && options !== void 0 ? options : {};
const sendEvents = !(options === null || options === void 0 ? void 0 : options.noEvents); // Default: send events
if (!flag &&
this.isExpanded() &&
this.getLevel() <= this.tree.getOption("minExpandLevel") &&
!force) {
this.logDebug("Ignored collapse request below minExpandLevel.");
return;
}
if (!flag === !this.expanded) {
return; // Nothing to do
}
if (sendEvents &&
this._callEvent("beforeExpand", { flag: flag }) === false) {
return;
}
// this.log("setExpanded()");
if (flag && this.getOption("autoCollapse")) {
this.collapseSiblings(options);
}
if (flag && this.lazy && this.children == null) {
await this.loadLazy();
}
else if (!flag && resetLazy && this.lazy && this.children) {
this.resetLazy();
}
this.expanded = flag;
const updateOpts = { immediate: immediate };
// const updateOpts = { immediate: !!util.getOption(options, "immediate") };
this.tree.update(ChangeType.structure, updateOpts);
if (flag && scrollIntoView) {
const lastChild = this.getLastChild();
if (lastChild) {
this.tree.updatePendingModifications();
lastChild.scrollIntoView({ topNode: this });
}
}
if (sendEvents) {
this._callEvent("expand", { flag: flag });
}
}
/**
* Set keyboard focus here.
* @see {@link setActive}
*/
setFocus(flag = true) {
assert(!!flag, "Blur is not yet implemented");
const prev = this.tree.focusNode;
this.tree._setFocusNode(this);
prev === null || prev === void 0 ? void 0 : prev.update();
this.update();
}
/** Set a new icon path or class. */
setIcon(icon) {
this.icon = icon;
this.update();
}
/** Change node's {@link key} and/or {@link refKey}. */
setKey(key, refKey) {
throw new Error("Not yet implemented");
}
/**
* Trigger a repaint, typically after a status or data change.
*
* `change` defaults to 'data', which handles modifcations of title, icon,
* and column content. It can be reduced to 'ChangeType.status' if only
* active/focus/selected state has changed.
*
* This method will eventually call {@link WunderbaumNode._render} with
* default options, but may be more consistent with the tree's
* {@link Wunderbaum.update} API.
*/
update(change = ChangeType.data) {
assert(change === ChangeType.status || change === ChangeType.data, `Invalid change type ${change}`);
this.tree.update(change, this);
}
/**
* Return an array of selected nodes.
* @param stopOnParents only return the topmost selected node (useful with selectMode 'hier')
*/
getSelectedNodes(stopOnParents = false) {
const nodeList = [];
this.visit((node) => {
if (node.selected) {
nodeList.push(node);
if (stopOnParents === true) {
return "skip"; // stop processing this branch
}
}
});
return nodeList;
}
/** Toggle the check/uncheck state. */
toggleSelected(options) {
let flag = this.isSelected();
if (flag === undefined && !this.isRadio()) {
flag = this._anySelectable();
}
else {
flag = !flag;
}
return this.setSelected(flag, options);
}
/** Return true if at least on selectable descendant end-node is unselected. @internal */
_anySelectable() {
let found = false;
this.visit((node) => {
if (node.selected === false &&
!node.unselectable &&
!node.hasChildren() &&
!node.parent.radiogroup) {
found = true;
return false; // Stop iteration
}
});
return found;
}
/* Apply selection state to a single node. */
_changeSelectStatusProps(state) {
let changed = false;
switch (state) {
case false:
changed = this.selected || this._partsel;
this.selected = false;
this._partsel = false;
break;
case true:
changed = !this.selected || !this._partsel;
this.selected = true;
this._partsel = true;
break;
case undefined:
changed = this.selected || !this._partsel;
this.selected = false;
// #110: end nodess cannot have a `_partsel` flag
this._partsel = this.hasChildren() ? true : false;
break;
default:
error(`Invalid state: ${state}`);
}
if (changed) {
this.update();
}
return changed;
}
/**
* Fix selection status, after this node was (de)selected in `selectMode: 'hier'`.
* This includes (de)selecting all descendants.
*/
fixSelection3AfterClick(opts) {
const force = !!(opts === null || opts === void 0 ? void 0 : opts.force);
const flag = this.isSelected();
this.visit((node) => {
if (node.radiogroup) {
return "skip"; // Don't (de)select this branch
}
if (force || !node.getOption("unselectable")) {
node._changeSelectStatusProps(flag);
}
});
this.fixSelection3FromEndNodes();
}
/**
* Fix selection status for multi-hier mode.
* Only end-nodes are considered to update the descendants branch and parents.
* Should be called after this node has loaded new children or after
* children have been modified using the API.
*/
fixSelection3FromEndNodes(opts) {
const force = !!(opts === null || opts === void 0 ? void 0 : opts.force);
assert(this.tree.options.selectMode === "hier", "expected selectMode 'hier'");
// Visit all end nodes and adjust their parent's `selected` and `_partsel`
// attributes. Return selection state true, false, or undefined.
const _walk = (node) => {
let state;
const children = node.children;
if (children && children.length) {
// check all children recursively
let allSelected = true;
let someSelected = false;
for (let i = 0, l = children.length; i < l; i++) {
const child = children[i];
// the selection state of a node is not relevant; we need the end-nodes
const s = _walk(child);
if (s !== false) {
someSelected = true;
}
if (s !== true) {
allSelected = false;
}
}
state = allSelected ? true : someSelected ? undefined : false;
}
else {
// This is an end-node: simply report the status
state = !!node.selected;
}
// #939: Keep a `_partsel` flag that was explicitly set on a lazy node
if (node._partsel &&
!node.selected &&
node.lazy &&
node.children == null) {
state = undefined;
}
if (force || !node.getOption("unselectable")) {
node._changeSelectStatusProps(state);
}
return state;
};
_walk(this);
// Update parent's state
this.visitParents((node) => {
let state;
const children = node.children;
let allSelected = true;
let someSelected = false;
for (let i = 0, l = children.length; i < l; i++) {
const child = children[i];
state = !!child.selected;
// When fixing the parents, we trust the sibling status (i.e. we don't recurse)
if (state || child._partsel) {
someSelected = true;
}
if (!state) {
allSelected = false;
}
}
state = allSelected ? true : someSelected ? undefined : false;
node._changeSelectStatusProps(state);
});
}
/** Modify the check/uncheck state. */
setSelected(flag = true, options) {
const tree = this.tree;
const sendEvents = !(options === null || options === void 0 ? void 0 : options.noEvents); // Default: send events
const prev = this.isSelected();
const isRadio = this.parent && this.parent.radiogroup;
const selectMode = tree.options.selectMode;
const canSelect = (options === null || options === void 0 ? void 0 : options.force) || !this.getOption("unselectable");
flag = !!flag;
// this.logDebug(`setSelected(${flag})`, this);
if (!canSelect) {
return prev;
}
if ((options === null || options === void 0 ? void 0 : options.propagateDown) && selectMode === "multi") {
tree.runWithDeferredUpdate(() => {
this.visit((node) => {
node.setSelected(flag);
});
});
return prev;
}
if (flag === prev ||
(sendEvents && this._callEvent("beforeSelect", { flag: flag }) === false)) {
return prev;
}
tree.runWithDeferredUpdate(() => {
if (isRadio) {
// Radiobutton Group
if (!flag && !(options === null || options === void 0 ? void 0 : options.force)) {
return prev; // don't uncheck radio buttons
}
for (const sibling of this.parent.children) {
sibling.selected = sibling === this;
}
}
else {
this.selected = flag;
if (selectMode === "hier") {
this.fixSelection3AfterClick();
}
else if (selectMode === "single") {
tree.visit((n) => {
n.selected = false;
});
}
}
});
if (sendEvents) {
this._callEvent("select", { flag: flag });
}
return prev;
}
/** Display node status (ok, loading, error, noData) using styles and a dummy child node. */
setStatus(status, options) {
const tree = this.tree;
const message = options === null || options === void 0 ? void 0 : options.message;
const details = options === null || options === void 0 ? void 0 : options.details;
let statusNode = null;
const _clearStatusNode = () => {
// Remove dedicated dummy node, if any
const children = this.children;
if (children && children.length && children[0].isStatusNode()) {
children[0].remove();
}
};
const _setStatusNode = (data) => {
// Create/modify the dedicated dummy node for 'loading...' or
// 'error!' status. (only called for direct child of the invisible
// system root)
const children = this.children;
const firstChild = children ? children[0] : null;
assert(data.statusNodeType, "Not a status node");
assert(!firstChild || !firstChild.isStatusNode(), "Child must not be a status node");
statusNode = this.addNode(data, "prependChild");
statusNode.match = -1; // Mark as 'match' to avoid hiding
tree.update(ChangeType.structure);
return statusNode;
};
_clearStatusNode();
switch (status) {
case "ok":
this._isLoading = false;
this._errorInfo = null;
break;
case "loading":
this._isLoading = true;
this._errorInfo = null;
if (this.parent) {
this.update(ChangeType.status);
}
else {
// If this is the invisible root, add a visible top-level node
_setStatusNode({
statusNodeType: status,
title: tree.options.strings.loading +
(message ? " (" + message + ")" : ""),
checkbox: false,
colspan: true,
tooltip: details,
});
}
// this.update();
break;
case "error":
_setStatusNode({
statusNodeType: status,
title: tree.options.strings.loadError +
(message ? " (" + message + ")" : ""),
checkbox: false,
colspan: true,
// classes: "wb-center",
tooltip: details,
});
this._isLoading = false;
this._errorInfo = { message: message, details: details };
break;
case "noData":
_setStatusNode({
statusNodeType: status,
title: message || tree.options.strings.noData,
checkbox: false,
colspan: true,
tooltip: details,
});
this._isLoading = false;
this._errorInfo = null;
break;
default:
error("invalid node status " + status);
}
tree.update(ChangeType.structure);
return statusNode;
}
/** Rename this node. */
setTitle(title) {
this.title = title;
this.update();
// this.triggerModify("rename"); // TODO
}
/** Set the node tooltip. */
setTooltip(tooltip) {
this.tooltip = tooltip;
this.update();
}
_sortChildren(cmp, deep) {
const cl = this.children;
if (!cl) {
return;
}
cl.sort(cmp);
if (deep) {
for (let i = 0, l = cl.length; i < l; i++) {
if (cl[i].children) {
cl[i]._sortChildren(cmp, deep);
}
}
}
}
/**
* Sort child list by title or custom criteria.
* @param {function} cmp custom compare function(a, b) that returns -1, 0, or 1
* (defaults to sorting by title).
* @param {boolean} deep pass true to sort all descendant nodes recursively
*/
sortChildren(cmp = nodeTitleSorter, deep = false) {
this._sortChildren(cmp || nodeTitleSorter, deep);
this.tree.update(ChangeType.structure);
// this.triggerModify("sort"); // TODO
}
/**
* Renumber nodes `_nativeIndex`. This is useful to allow to restore the
* order after sorting a column.
* This method is automatically called after loading new child nodes.
* @since 0.11.0
*/
resetNativeChildOrder(options) {
const { recursive = true, propName = "_nativeIndex" } = options !== null && options !== void 0 ? options : {};
if (this.children) {
this.children.forEach((child, i) => {
child.data[propName] = i;
if (recursive && child.children) {
child.resetNativeChildOrder(options);
}
});
}
}
/**
* Convenience method to implement column sorting.
* @since 0.11.0
*/
sortByProperty(options) {
var _a, _b, _c;
const { caseInsensitive = true, deep = true, nativeOrderPropName = "_nativeIndex", updateColInfo = false, } = options;
let order;
let colDef;
if (updateColInfo) {
colDef = this.tree["_columnsById"][options.colId];
assert(colDef, `Invalid colId specified: ${options.colId}`);
order =
(_a = options.order) !== null && _a !== void 0 ? _a : rotate(colDef.sortOrder, ["asc", "desc", undefined]);
for (const col of this.tree.columns) {
col.sortOrder = col === colDef ? order : undefined;
}
this.tree.update(ChangeType.colStructure);
}
else {
order = (_b = options.order) !== null && _b !== void 0 ? _b : "asc";
}
let propName = (_c = options.propName) !== null && _c !== void 0 ? _c : (options.colId || "");
if (propName === "*") {
propName = "title";
}
if (order == null) {
propName = nativeOrderPropName;
order = "asc";
}
this.logDebug(`sortByProperty(), propName=${propName}, ${order}`, options);
assert(propName, "No property name specified");
const cmp = (a, b) => {
let av, bv;
if (NODE_DICT_PROPS.has(propName)) {
av = a[propName];
bv = b[propName];
}
else {
av = a.data[propName];
bv = b.data[propName];
}
if (av == null && bv == null) {
return 0;
}
if (av == null) {
av = typeof bv === "string" ? "" : 0;
}
else if (typeof av === "boolean") {
av = av ? 1 : 0;
}
if (bv == null) {
bv = typeof av === "string" ? "" : 0;
}
else if (typeof bv === "boolean") {
bv = bv ? 1 : 0;
}
if (caseInsensitive) {
if (typeof av === "string") {
av = av.toLowerCase();
}
if (typeof bv === "string") {
bv = bv.toLowerCase();
}
}
if (order === "desc") {
return av === bv ? 0 : av > bv ? -1 : 1;
}
return av === bv ? 0 : av > bv ? 1 : -1;
};
return this.sortChildren(cmp, deep);
}
/**
* Trigger `modifyChild` event on a parent to signal that a child was modified.
* @param {string} operation Type of change: 'add', 'remove', 'rename', 'move', 'data', ...
*/
triggerModifyChild(operation, child, extra) {
this.logDebug(`modifyChild(${operation})`, extra, child);
if (!this.tree.options.modifyChild) {
return;
}
if (child && child.parent !== this) {
error("child " + child + " is not a child of " + this);
}
this._callEvent("modifyChild", extend({ operation: operation, child: child }, extra));
}
/**
* Trigger `modifyChild` event on node.parent(!).
* @param {string} operation Type of change: 'add', 'remove', 'rename', 'move', 'data', ...
* @param {object} [extra]
*/
triggerModify(operation, extra) {
// if (!this.parent) {
// return;
// }
this.parent.triggerModifyChild(operation, this, extra);
}
/**
* Call `callback(node)` for all descendant nodes in hierarchical order (depth-first, pre-order).
*
* Stop iteration, if fn() returns false. Skip current branch, if fn()
* returns "skip".<br>
* Return false if iteration was stopped.
*
* @param {function} callback the callback function.
* Return false to stop iteration, return "skip" to skip this node and
* its children only.
* @see {@link IterableIterator<WunderbaumNode>}, {@link Wunderbaum.visit}.
*/
visit(callback, includeSelf = false) {
let res = true;
const children = this.children;
if (includeSelf === true) {
res = callback(this);
if (res === false || res === "skip") {
return res;
}
}
if (children) {
for (let i = 0, l = children.length; i < l; i++) {
res = children[i].visit(callback, true);
if (res === false) {
break;
}
}
}
return res;
}
/** Call fn(node) for all parent nodes, bottom-up, including invisible system root.<br>
* Stop iteration, if callback() returns false.<br>
* Return false if iteration was stopped.
*
* @param callback the callback function. Return false to stop iteration
*/
visitParents(callback, includeSelf = false) {
if (includeSelf && callback(this) === false) {
return false;
}
let p = this.parent;
while (p) {
if (callback(p) === false) {
return false;
}
p = p.parent;
}
return true;
}
/**
* Call fn(node) for all sibling nodes.<br>
* Stop iteration, if fn() returns false.<br>
* Return false if iteration was stopped.
*
* @param callback the callback function.
* Return false to stop iteration.
* @param includeSelf include this node in the iteration.
*/
visitSiblings(callback, includeSelf = false) {
const ac = this.parent.children;
for (let i = 0, l = ac.length; i < l; i++) {
const n = ac[i];
if (includeSelf || n !== this) {
if (callback(n) === false) {
return false;
}
}
}
return true;
}
/**
* [ext-filter] Return true if this node is matched by current filter (or no filter is active).
*/
isMatched() {
return !(this.tree.filterMode && !this.match);
}
}
WunderbaumNode.sequence = 0;
/*!
* Wunderbaum - ext-edit
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* v0.13.0, Sat, 08 Mar 2025 14:16:31 GMT (https://github.com/mar10/wunderbaum)
*/
// const START_MARKER = "\uFFF7";
class EditExtension extends WunderbaumExtension {
constructor(tree) {
super(tree, "edit", {
debounce: 100,
minlength: 1,
maxlength: null,
trigger: [], //["clickActive", "F2", "macEnter"],
trim: true,
select: true,
slowClickDelay: 1000, // Handle 'clickActive' only if last click is less than this old (0: always)
validity: true, //"Please enter a title",
// --- Events ---
// (note: there is also the `tree.change` event.)
beforeEdit: null,
edit: null,
apply: null,
});
this.curEditNode = null;
this.relatedNode = null;
this.debouncedOnChange = debounce(this._onChange.bind(this), this.getPluginOption("debounce"));
}
/*
* Call an event handler, while marking the current node cell 'busy'.
* Deal with returned promises and ValidationError.
* Convert a ValidationError into a input.setCustomValidity() call and vice versa.
*/
async _applyChange(eventName, node, colElem, inputElem, extra) {
node.log(`_applyChange(${eventName})`, extra);
colElem.classList.add("wb-busy");
colElem.classList.remove("wb-error", "wb-invalid");
inputElem.setCustomValidity("");
// Call event handler either ('change' or 'edit.appy'), which may return a
// promise or a scalar value or throw a ValidationError.
return new Promise((resolve, reject) => {
const res = node._callEvent(eventName, extra);
// normalize to promise, even if a scalar value was returned and await it
Promise.resolve(res)
.then((res) => {
resolve(res);
})
.catch((err) => {
reject(err);
});
})
.then((res) => {
if (!inputElem.checkValidity()) {
// Native validation failed or handler called 'inputElem.setCustomValidity()'
node.logWarn("inputElem.checkValidity() failed: throwing....");
throw new ValidationError(inputElem.validationMessage);
}
return res;
})
.catch((err) => {
if (err instanceof ValidationError) {
node.logWarn("catched ", err);
colElem.classList.add("wb-invalid");
if (inputElem.setCustomValidity && !inputElem.validationMessage) {
inputElem.setCustomValidity(err.message);
}
if (inputElem.validationMessage) {
inputElem.reportValidity();
}
// throw err;
}
else {
node.logError(`Error in ${eventName} event handler (throw e.util.ValidationError instead if this was intended)`, err);
colElem.classList.add("wb-error");
throw err;
}
})
.finally(() => {
colElem.classList.remove("wb-busy");
});
}
/*
* Called for when a control that is embedded in a cell fires a `change` event.
*/
_onChange(e) {
const info = Wunderbaum.getEventInfo(e);
const node = info.node;
const colElem = info.colElem;
if (!node || info.colIdx === 0) {
this.tree.log("Ignored change event for removed element or node title");
return;
}
// See also WbChangeEventType
this._applyChange("change", node, colElem, e.target, {
info: info,
event: e,
inputElem: e.target,
inputValue: Wunderbaum.util.getValueFromElem(e.target),
inputValid: e.target.checkValidity(),
});
}
init() {
super.init();
onEvent(this.tree.element, "change", //"change input",
".contenteditable,input,textarea,select",
// #61: we must not debounce the `change`, event.target may be reset to null
// when the debounced handler is called.
// (e) => {
// this.debouncedOnChange(e);
// }
(e) => this._onChange(e));
}
/* Called by ext_keynav to pre-process input. */
_preprocessKeyEvent(data) {
const event = data.event;
const eventName = eventToString(event);
const tree = this.tree;
const trigger = this.getPluginOption("trigger");
// const inputElem =
// event.target && event.target.closest("input,[contenteditable]");
// tree.logDebug(`_preprocessKeyEvent: ${eventName}, editing:${this.isEditingTitle()}`);
// --- Title editing: apply/discard ---
// if (inputElem) {
if (this.isEditingTitle()) {
switch (eventName) {
case "Enter":
this._stopEditTitle(true, { event: event });
return false;
case "Escape":
this._stopEditTitle(false, { event: event });
return false;
}
// If the event target is an input element or `contenteditable="true"`,
// we ignore it as navigation command
return false;
}
// --- Trigger title editing
if (tree.isRowNav() || tree.activeColIdx === 0) {
switch (eventName) {
case "Enter":
if (trigger.indexOf("macEnter") >= 0 && isMac) {
this.startEditTitle();
return false;
}
break;
case "F2":
if (trigger.indexOf("F2") >= 0) {
this.startEditTitle();
return false;
}
break;
}
return true;
}
return true;
}
/** Return true if a title is currently being edited. */
isEditingTitle(node) {
return node ? this.curEditNode === node : !!this.curEditNode;
}
/** Start renaming, i.e. replace the title with an embedded `<input>`. */
startEditTitle(node) {
node = node !== null && node !== void 0 ? node : this.tree.getActiveNode();
const validity = this.getPluginOption("validity");
const select = this.getPluginOption("select");
if (!node) {
return;
}
if (node.isStatusNode()) {
node.logWarn("Cannot edit status node.");
return;
}
this.tree.logDebug(`startEditTitle(node=${node})`);
let inputHtml = node._callEvent("edit.beforeEdit");
if (inputHtml === false) {
node.logDebug("beforeEdit canceled operation.");
return;
}
// `beforeEdit(e)` may return an input HTML string. Otherwise use a default
// (we also treat a `true` return value as 'use default'):
if (inputHtml === true || !inputHtml) {
const title = escapeHtml(node.title);
let opt = this.getPluginOption("maxlength");
const maxlength = opt ? ` maxlength="${opt}"` : "";
opt = this.getPluginOption("minlength");
const minlength = opt ? ` minlength="${opt}"` : "";
const required = opt > 0 ? " required" : "";
inputHtml =
`<input type=text class="wb-input-edit" tabindex=-1 value="${title}" ` +
`autocorrect="off"${required}${minlength}${maxlength} >`;
}
const titleSpan = node
.getColElem(0)
.querySelector(".wb-title");
titleSpan.innerHTML = inputHtml;
const inputElem = titleSpan.firstElementChild;
if (validity) {
// Permanently apply input validations (CSS and tooltip)
inputElem.addEventListener("keydown", (e) => {
inputElem.setCustomValidity("");
if (!inputElem.reportValidity()) {
node.logWarn(`Invalid input: '${inputElem.value}'`);
}
});
}
inputElem.focus();
if (select) {
inputElem.select();
}
this.curEditNode = node;
node._callEvent("edit.edit", {
inputElem: inputElem,
});
}
/**
*
* @param apply
* @returns
*/
stopEditTitle(apply) {
return this._stopEditTitle(apply, {});
}
/*
*
* @param apply
* @param opts.canKeepOpen
*/
_stopEditTitle(apply, options) {
var _a;
options !== null && options !== void 0 ? options : (options = {});
const focusElem = document.activeElement;
let newValue = focusElem ? getValueFromElem(focusElem) : null;
const node = this.curEditNode;
const forceClose = !!options.forceClose;
const validity = this.getPluginOption("validity");
if (newValue && this.getPluginOption("trim")) {
newValue = newValue.trim();
}
if (!node) {
this.tree.logDebug("stopEditTitle: not in edit mode.");
return;
}
node.logDebug(`stopEditTitle(${apply})`, options, focusElem, newValue);
if (apply && newValue !== null && newValue !== node.title) {
const errMsg = focusElem.validationMessage;
if (errMsg) {
// input element's native validation failed
throw new Error(`Input validation failed for "${newValue}": ${errMsg}.`);
}
const colElem = node.getColElem(0);
this._applyChange("edit.apply", node, colElem, focusElem, {
oldValue: node.title,
newValue: newValue,
inputElem: focusElem,
inputValid: focusElem.checkValidity(),
}).then((value) => {
var _a;
const errMsg = focusElem.validationMessage;
if (validity && errMsg && value !== false) {
// Handler called 'inputElem.setCustomValidity()' to signal error
throw new Error(`Edit apply validation failed for "${newValue}": ${errMsg}.`);
}
// Discard the embedded `<input>`
// node.logDebug("applyChange:", value, forceClose)
if (!forceClose && value === false) {
// Keep open
return;
}
node === null || node === void 0 ? void 0 : node.setTitle(newValue);
// NOTE: At least on Safari, this render call triggers a scroll event
// probably because the focused input is replaced.
(_a = this.curEditNode) === null || _a === void 0 ? void 0 : _a._render({ preventScroll: true });
this.curEditNode = null;
this.relatedNode = null;
this.tree.setFocus(); // restore focus that was in the input element
});
// .catch((err) => {
// node.logError(err);
// });
// Trigger 'change' event for embedded `<input>`
// focusElem.blur();
}
else {
// Discard the embedded `<input>`
// NOTE: At least on Safari, this render call triggers a scroll event
// probably because the focused input is replaced.
(_a = this.curEditNode) === null || _a === void 0 ? void 0 : _a._render({ preventScroll: true });
this.curEditNode = null;
this.relatedNode = null;
// We discarded the <input>, so we have to acquire keyboard focus again
this.tree.setFocus();
}
}
/**
* Create a new child or sibling node and start edit mode.
*/
createNode(mode = "after", node, init) {
const tree = this.tree;
node = node !== null && node !== void 0 ? node : tree.getActiveNode();
assert(node, "No node was passed, or no node is currently active.");
// const validity = this.getPluginOption("validity");
mode = mode || "prependChild";
if (init == null) {
init = { title: "" };
}
else if (typeof init === "string") {
init = { title: init };
}
else {
assert(isPlainObject(init), `Expected a plain object: ${init}`);
}
// Make sure node is expanded (and loaded) in 'child' mode
if ((mode === "prependChild" || mode === "appendChild") &&
(node === null || node === void 0 ? void 0 : node.isExpandable(true))) {
node.setExpanded().then(() => {
this.createNode(mode, node, init);
});
return;
}
const newNode = node.addNode(init, mode);
newNode.setClass("wb-edit-new");
this.relatedNode = node;
// Don't filter new nodes:
newNode.match = -1;
newNode.makeVisible({ noAnimation: true }).then(() => {
this.startEditTitle(newNode);
});
}
}
/*!
* 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 v0.13.0
* @date Sat, 08 Mar 2025 14:16:31 GMT
*/
// import "./wunderbaum.scss";
class WbSystemRoot extends WunderbaumNode {
constructor(tree) {
super(tree, null, {
key: "__root__",
title: tree.id,
});
}
toString() {
return `WbSystemRoot@${this.key}<'${this.tree.id}'>`;
}
}
/**
* A persistent plain object or array.
*
* See also {@link WunderbaumOptions}.
*/
class Wunderbaum {
/** Currently active node if any.
* Use {@link WunderbaumNode.setActive|setActive} to modify.
*/
get activeNode() {
var _a;
// Check for deleted node, i.e. node.tree === null
return ((_a = this._activeNode) === null || _a === void 0 ? void 0 : _a.tree) ? this._activeNode : null;
}
/** Current node hat has keyboard focus if any.
* Use {@link WunderbaumNode.setFocus|setFocus()} to modify.
*/
get focusNode() {
var _a;
// Check for deleted node, i.e. node.tree === null
return ((_a = this._focusNode) === null || _a === void 0 ? void 0 : _a.tree) ? this._focusNode : null;
}
constructor(options) {
this.enabled = true;
/** Contains additional data that was sent as response to an Ajax source load request. */
this.data = {};
this.extensionList = [];
this.extensions = {};
this.keyMap = new Map();
this.refKeyMap = new Map();
this.treeRowCount = 0;
this._disableUpdateCount = 0;
this._disableUpdateIgnoreCount = 0;
this._activeNode = null;
this._focusNode = null;
/** Shared properties, referenced by `node.type`. */
this.types = {};
/** List of column definitions. */
this.columns = []; // any[] = [];
this._columnsById = {};
// Modification Status
this.pendingChangeTypes = new Set();
/** Expose some useful methods of the util.ts module as `tree._util`. */
this._util = util;
// --- SELECT ---
// /** @internal */
// public selectRangeAnchor: WunderbaumNode | null = null;
// --- BREADCRUMB ---
/** Filter options (used as defaults for calls to {@link Wunderbaum.filterNodes} ) */
this.breadcrumb = null;
// --- FILTER ---
/** Filter options (used as defaults for calls to {@link Wunderbaum.filterNodes} ) */
this.filterMode = null;
// --- KEYNAV ---
/** @internal Use `setColumn()`/`getActiveColElem()` to access. */
this.activeColIdx = 0;
/** @internal */
this._cellNavMode = false;
/** @internal */
this.lastQuicksearchTime = 0;
/** @internal */
this.lastQuicksearchTerm = "";
// --- EDIT ---
this.lastClickTime = 0;
const opts = (this.options = 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 = 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 = elemFromSelector(opts.element);
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>
this.element._wb_tree = this;
// Create header markup, or take it from the existing html
this.headerElement =
this.element.querySelector("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`
assert(!this.columns, "`opts.columns` must not be set if table markup already contains a header");
this.columns = [];
const rowElement = this.headerElement.querySelector("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("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("div.wb-list-container");
this.nodeListElement =
this.listContainerElement.querySelector("div.wb-node-list");
this.headerElement =
this.element.querySelector("div.wb-header");
this.element.classList.toggle("wb-grid", this.columns.length > 1);
if (this.options.connectTopBreadcrumb) {
this.breadcrumb = elemFromSelector(this.options.connectTopBreadcrumb);
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;
if (elem && elem.matches("a.wb-breadcrumb")) {
const node = this.keyMap.get(elem.dataset.key);
node === null || node === void 0 ? void 0 : 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(() => {
var _a;
(_a = this.element.querySelector("progress.spinner")) === null || _a === void 0 ? void 0 : _a.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) => {
// 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);
onEvent(this.element, "click", ".wb-button,.wb-col-icon", (e) => {
var _a, _b;
const info = Wunderbaum.getEventInfo(e);
const command = (_b = (_a = e.target) === null || _a === void 0 ? void 0 : _a.dataset) === null || _b === void 0 ? void 0 : _b.command;
this._callEvent("buttonClick", {
event: e,
info: info,
command: command,
});
});
onEvent(this.nodeListElement, "click", "div.wb-row", (e) => {
const info = Wunderbaum.getEventInfo(e);
const node = info.node;
const mouseEvent = e;
// 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();
});
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());
}
});
onEvent(this.element, "keydown", (e) => {
const info = Wunderbaum.getEventInfo(e);
const eventName = eventToString(e);
const node = info.node || this.getFocusNode();
this._callHook("onKeyEvent", {
event: e,
node: node,
info: info,
eventName: eventName,
});
});
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 === NavModeEnum.row) {
targetNode === null || targetNode === void 0 ? void 0 : 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
* ```
*/
static getTree(el) {
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 = treeElem._wb_tree;
if (tree && tree.id === el) {
return tree;
}
}
// Search by selector
el = document.querySelector(el);
if (!el) {
return null;
}
}
else if (el.target) {
el = el.target;
}
assert(el instanceof Element, `Invalid el type: ${el}`);
if (!el.matches(".wunderbaum")) {
el = el.closest(".wunderbaum");
}
if (el && el._wb_tree) {
return el._wb_tree;
}
return null;
}
/**
* Return the icon-function -> icon-definition mapping.
*/
get iconMap() {
const map = this.options.iconMap;
if (typeof map === "string") {
return iconMaps[map];
}
return map;
}
/**
* Return a WunderbaumNode instance from element or event.
*/
static getNode(el) {
if (!el) {
return null;
}
else if (el instanceof WunderbaumNode) {
return el;
}
else if (el.target !== undefined) {
el = el.target; // el was an Event
}
// `el` is a DOM element
// let nodeElem = obj.closest("div.wb-row");
while (el) {
if (el._wb_node) {
return el._wb_node;
}
el = 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]() {
yield* this.root;
}
/** @internal */
_registerExtension(extension) {
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. */
_initExtensions() {
for (const ext of this.extensionList) {
ext.init();
}
}
/** Add node to tree's bookkeeping data structures. */
_registerNode(node) {
const key = node.key;
assert(key != null, `Missing key: '${node}'.`);
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) {
// 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 = null;
node.parent = null;
// Remove HTML markup
node.removeMarkup();
}
/** Call all hook methods of all registered extensions.*/
_callHook(hook, data = {}) {
let res;
const d = extend({}, { tree: this, options: this.options, result: undefined }, data);
for (const ext of this.extensionList) {
res = 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, ...args) {
const [p, n] = name.split(".");
const obj = n ? this.extensions[p] : this;
const func = 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, extra) {
const [p, n] = type.split(".");
const opts = this.options;
const func = n ? opts[p][n] : opts[p];
if (func) {
return func.call(this, extend({ type: type, tree: this, util: this._util }, extra));
// } else {
// this.logError(`Triggering undefined event '${type}'.`)
}
}
/** Return the node for given row index. */
_getNodeByRowIdx(idx) {
// TODO: start searching from active node (reverse)
let node = null;
this.visitRows((n) => {
if (n._rowIdx === idx) {
node = n;
return false;
}
});
return 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;
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;
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. */
_getNextNodeInView(node, options) {
let ofs = (options === null || options === void 0 ? void 0 : options.ofs) || 1;
const reverse = !!(options === null || options === void 0 ? void 0 : options.reverse);
this.visitRows((n) => {
node = n;
if ((options === null || options === void 0 ? void 0 : 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, options) {
return this.root.addChildren(nodeData, options);
}
/**
* 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, nodeOrOpts, options) {
let // clipboard,
node, refNode;
// options = $.extend(
// { setActive: true, clipboard: CLIPBOARD },
// options_
// );
if (nodeOrOpts instanceof WunderbaumNode) {
node = nodeOrOpts;
}
else {
node = this.getActiveNode();
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:
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.
*/
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, defaultValue) {
let ext;
let opts = this.options;
// 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 !== null && value !== void 0 ? value : defaultValue;
}
/**
* Set tree option.
* Use dot notation to set plugin option, e.g. "filter.mode".
*/
setOption(name, value) {
// 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[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, hint = null) {
try {
this.enableUpdate(false);
const res = func();
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 = true, options) {
await this.root.expandAll(flag, options);
}
/** Recursively select all nodes. */
selectAll(flag = 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 = false) {
return this.root.getSelectedNodes(stopOnParents);
}
/*
* Return an array of selected nodes.
*/
_selectRange(eventInfo) {
this.logDebug("_selectRange", eventInfo);
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) {
return visible ? this.treeRowCount : this.keyMap.size;
}
/** Return the number of *unique* nodes in the data model, i.e. unique `node.refKey`.
*/
countUnique() {
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) {
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) {
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) {
return this.root.findFirst(match);
}
/**
* Find first node that matches condition.
*
* @see {@link WunderbaumNode.findFirst}
*
*/
findKey(key) {
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, startNode, reverse = false) {
//, visibleOnly) {
let res = 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) {
// 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, where, 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 === null || res === void 0 ? void 0 : res.setActive();
break;
default:
this.logWarn("Unknown relation '" + where + "'.");
}
return res;
}
/**
* Iterator version of {@link Wunderbaum.format}.
*/
*format_iter(name_cb, connectors) {
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, connectors) {
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) {
const target = event.target;
const cl = target.classList;
const parentCol = target.closest("span.wb-col");
const node = Wunderbaum.getNode(target);
const tree = node ? node.tree : Wunderbaum.getTree(event);
const res = {
event: event,
canonicalName: 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 === null || tree === void 0 ? void 0 : tree.logWarn("getEventInfo(): not found", event, res);
}
return res;
}
if (res.colIdx === -1) {
res.colIdx = 0;
}
res.colDef = tree === null || tree === void 0 ? void 0 : tree.columns[res.colIdx];
res.colDef != null ? (res.colId = 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() {
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() {
return this._callMethod("edit.isEditingTitle");
}
/**
* Return true if any node is currently beeing loaded, i.e. a Ajax request is pending.
*/
isLoading() {
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) {
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) {
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) {
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) {
if (this.options.debugLevel >= 3) {
console.info(this.toString(), ...args); // eslint-disable-line no-console
}
}
/** @internal */
logTime(label) {
if (this.options.debugLevel >= 4) {
console.time(this + ": " + label); // eslint-disable-line no-console
}
return label;
}
/** @internal */
logTimeEnd(label) {
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) {
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 `_nativeIndex`. @see {@link WunderbaumNode.resetNativeChildOrder} */
// resetNativeChildOrder(options?: ResetOrderOptions) {
// this.root.resetNativeChildOrder(options);
// }
/**
* Make sure that this node is vertically scrolled into the viewport.
*
* Nodes that are above the visible area become the top row, nodes that are
* below the viewport become the bottom row.
*/
scrollTo(nodeOrOpts) {
const PADDING = 2; // leave some pixels between viewport bounds
let node;
// WunderbaumNode;
let options;
if (nodeOrOpts instanceof WunderbaumNode) {
node = nodeOrOpts;
}
else {
options = nodeOrOpts;
node = options.node;
}
assert(node && node._rowIdx != null, `Invalid node: ${node}`);
const rowHeight = this.options.rowHeightPx;
const scrollParent = this.element;
const headerHeight = this.headerElement.clientHeight; // May be 0
const scrollTop = scrollParent.scrollTop;
const vpHeight = scrollParent.clientHeight;
const rowTop = node._rowIdx * rowHeight + headerHeight;
const vpTop = headerHeight;
const vpRowTop = rowTop - scrollTop;
const vpRowBottom = vpRowTop + rowHeight;
const topNode = options === null || options === void 0 ? void 0 : options.topNode;
// this.log( `scrollTo(${node.title}), vpTop:${vpTop}px, scrollTop:${scrollTop}, vpHeight:${vpHeight}, rowTop:${rowTop}, vpRowTop:${vpRowTop}`, nodeOrOpts , options);
let newScrollTop = null;
if (vpRowTop >= vpTop) {
if (vpRowBottom <= vpHeight) ;
else {
// Node is below viewport
// this.log("Below viewport");
newScrollTop = rowTop + rowHeight - vpHeight + PADDING; // leave some pixels between viewport bounds
}
}
else {
// Node is above viewport
// this.log("Above viewport");
newScrollTop = rowTop - vpTop - PADDING; // leave some pixels between viewport bounds
}
if (newScrollTop != null) {
this.log(`scrollTo(${rowTop}): ${scrollTop} => ${newScrollTop}`);
scrollParent.scrollTop = newScrollTop;
if (topNode) {
// Make sure the topNode is always visible
this.scrollTo(topNode);
}
// this.update(ChangeType.scroll);
}
}
/**
* Make sure that this node is horizontally scrolled into the viewport.
* Called by {@link setColumn}.
*/
scrollToHorz() {
// const PADDING = 1;
const fixedWidth = this.columns[0]._widthPx;
const vpWidth = this.element.clientWidth;
const scrollLeft = this.element.scrollLeft;
const colElem = this.getActiveColElem();
const colLeft = Number.parseInt(colElem === null || colElem === void 0 ? void 0 : colElem.style.left, 10);
const colRight = colLeft + Number.parseInt(colElem === null || colElem === void 0 ? void 0 : colElem.style.width, 10);
let newLeft = scrollLeft;
if (colLeft - scrollLeft < fixedWidth) {
// The current column is scrolled behind the left fixed column
newLeft = colLeft - fixedWidth;
}
else if (colRight - scrollLeft > vpWidth) {
// The current column is scrolled outside the right side
newLeft = colRight - vpWidth;
}
newLeft = Math.max(0, newLeft);
// util.assert(node._rowIdx != null);
this.log(`scrollToHorz(${this.activeColIdx}): ${colLeft}..${colRight}, fixedOfs=${fixedWidth}, vpWidth=${vpWidth}, curLeft=${scrollLeft} -> ${newLeft}`);
this.element.scrollLeft = newLeft;
// this.update(ChangeType.scroll);
}
/**
* Set column #colIdx to 'active'.
*
* This higlights the column header and -cells by adding the `wb-active`
* class to all grid cells of the active column. <br>
* Available in cell-nav mode only.
*
* If _options.edit_ is true, the embedded input element is focused, or if
* colIdx is 0, the node title is put into edit mode.
*/
setColumn(colIdx, options) {
var _a, _b, _c;
const edit = options === null || options === void 0 ? void 0 : options.edit;
const scroll = (options === null || options === void 0 ? void 0 : options.scrollIntoView) !== false;
assert(this.isCellNav(), "Expected cellNav mode");
if (typeof colIdx === "string") {
const cid = colIdx;
colIdx = this.columns.findIndex((c) => c.id === colIdx);
assert(colIdx >= 0, `Invalid colId: ${cid}`);
}
assert(0 <= colIdx && colIdx < this.columns.length, `Invalid colIdx: ${colIdx}`);
this.activeColIdx = colIdx;
// Update `wb-active` class for all headers
if (this.hasHeader()) {
for (const rowDiv of this.headerElement.children) {
let i = 0;
for (const colDiv of rowDiv.children) {
colDiv.classList.toggle("wb-active", i++ === colIdx);
}
}
}
(_a = this.activeNode) === null || _a === void 0 ? void 0 : _a.update(ChangeType.status);
// Update `wb-active` class for all cell spans
for (const rowDiv of this.nodeListElement.children) {
let i = 0;
for (const colDiv of rowDiv.children) {
colDiv.classList.toggle("wb-active", i++ === colIdx);
}
}
// Horizontically scroll into view
if (scroll || edit) {
this.scrollToHorz();
}
if (edit && this.activeNode) {
// this.activeNode.setFocus(); // Blur prev. input if any
if (colIdx === 0) {
this.activeNode.startEditTitle();
}
else {
(_c = (_b = this.getActiveColElem()) === null || _b === void 0 ? void 0 : _b.querySelector("input,select")) === null || _c === void 0 ? void 0 : _c.focus();
}
}
}
/* Set or remove keyboard focus to the tree container. @internal */
_setActiveNode(node) {
this._activeNode = node;
}
/** Set or remove keyboard focus to the tree container. */
setActiveNode(key, flag = true, options) {
var _a;
(_a = this.findKey(key)) === null || _a === void 0 ? void 0 : _a.setActive(flag, options);
}
/** Set or remove keyboard focus to the tree container. */
setFocus(flag = true) {
if (flag) {
this.element.focus();
}
else {
this.element.blur();
}
}
/* Set or remove keyboard focus to the tree container. @internal */
_setFocusNode(node) {
this._focusNode = node;
}
/** Return the current selection/expansion/activation status. @experimental */
getState(options) {
var _a, _b;
let expandedKeys = undefined;
if (options.expandedKeys !== false) {
expandedKeys = [];
for (const node of this) {
if (node.expanded) {
expandedKeys.push(node.key);
}
}
}
const state = {
activeKey: (_b = (_a = this.activeNode) === null || _a === void 0 ? void 0 : _a.key) !== null && _b !== void 0 ? _b : null,
activeColIdx: this.activeColIdx,
selectedKeys: options.selectedKeys === false
? undefined
: this.getSelectedNodes().flatMap((n) => n.key),
expandedKeys: expandedKeys,
};
return state;
}
/** Apply selection/expansion/activation status. @experimental */
setState(state, options) {
this.runWithDeferredUpdate(() => {
var _a, _b;
if (state.selectedKeys) {
this.selectAll(false);
for (const key of state.selectedKeys) {
(_a = this.findKey(key)) === null || _a === void 0 ? void 0 : _a.setSelected(true);
}
}
if (state.expandedKeys) {
for (const key of state.expandedKeys) {
(_b = this.findKey(key)) === null || _b === void 0 ? void 0 : _b.setExpanded(true);
}
}
if (state.activeKey) {
this.setActiveNode(state.activeKey);
}
if (state.activeColIdx != null) {
this.setColumn(state.activeColIdx);
}
});
}
update(change, node, options) {
// this.log(`update(${change}) node=${node}`);
if (!(node instanceof WunderbaumNode)) {
options = node;
node = undefined;
}
const immediate = !!getOption(options, "immediate");
const RF = RenderFlag;
const pending = this.pendingChangeTypes;
if (this._disableUpdateCount) {
// Assuming that we redraw all when enableUpdate() is re-enabled.
// this.log(
// `IGNORED update(${change}) node=${node} (disable level ${this._disableUpdateCount})`
// );
this._disableUpdateIgnoreCount++;
return;
}
switch (change) {
case ChangeType.any:
case ChangeType.colStructure:
pending.add(RF.header);
pending.add(RF.clearMarkup);
pending.add(RF.redraw);
pending.add(RF.scroll);
break;
case ChangeType.resize:
// case ChangeType.colWidth:
pending.add(RF.header);
pending.add(RF.redraw);
break;
case ChangeType.structure:
pending.add(RF.redraw);
break;
case ChangeType.scroll:
pending.add(RF.scroll);
break;
case ChangeType.row:
case ChangeType.data:
case ChangeType.status:
assert(node, `Option '${change}' requires a node.`);
// Single nodes are immediately updated if already inside the viewport
// (otherwise we can ignore)
if (node._rowElem) {
node._render({ change: change });
}
break;
default:
error(`Invalid change type '${change}'.`);
}
if (change === ChangeType.colStructure) {
const isGrid = this.isGrid();
this.element.classList.toggle("wb-grid", isGrid);
if (!isGrid && this.isCellNav()) {
this.setCellNav(false);
}
}
if (pending.size > 0) {
if (immediate) {
this._updateViewportImmediately();
}
else {
this._updateViewportThrottled();
}
}
}
/** Disable mouse and keyboard interaction (return prev. state). */
setEnabled(flag = true) {
const prev = this.enabled;
this.enabled = !!flag;
this.element.classList.toggle("wb-disabled", !flag);
return prev;
}
/** Return false if tree is disabled. */
isEnabled() {
return this.enabled;
}
/** Return true if tree has more than one column, i.e. has additional data columns. */
isGrid() {
return this.columns && this.columns.length > 1;
}
/** Return true if cell-navigation mode is active. */
isCellNav() {
return !!this._cellNavMode;
}
/** Return true if row-navigation mode is active. */
isRowNav() {
return !this._cellNavMode;
}
/** Set the tree's navigation mode. */
setCellNav(flag = true) {
var _a;
const prev = this._cellNavMode;
// if (flag === prev) {
// return;
// }
this._cellNavMode = !!flag;
if (flag && !prev) {
// switch from row to cell mode
this.setColumn(0);
}
this.element.classList.toggle("wb-cell-mode", flag);
(_a = this.activeNode) === null || _a === void 0 ? void 0 : _a.update(ChangeType.status);
}
/** Set the tree's navigation mode option. */
setNavigationOption(mode, reset = false) {
if (!this.isGrid() && mode !== NavModeEnum.row) {
this.logWarn("Plain trees only support row navigation mode.");
return;
}
this.options.navigationModeOption = mode;
switch (mode) {
case NavModeEnum.cell:
this.setCellNav(true);
break;
case NavModeEnum.row:
this.setCellNav(false);
break;
case NavModeEnum.startCell:
if (reset) {
this.setCellNav(true);
}
break;
case NavModeEnum.startRow:
if (reset) {
this.setCellNav(false);
}
break;
default:
error(`Invalid mode '${mode}'.`);
}
}
/** Display tree status (ok, loading, error, noData) using styles and a dummy root node. */
setStatus(status, options) {
return this.root.setStatus(status, options);
}
/** Add or redefine node type definitions. */
setTypes(types, replace = true) {
assert(isPlainObject(types), `Expected plain objext: ${types}`);
if (replace) {
this.types = types;
}
else {
extend(this.types, types);
}
// Convert `TYPE.classes` to a Set
for (const t of Object.values(this.types)) {
if (t.classes) {
t.classes = toSet(t.classes);
}
}
}
/**
* Sort nodes list by title or custom criteria.
* @param {function} cmp custom compare function(a, b) that returns -1, 0, or 1
* (defaults to sorting by title).
* @param {boolean} deep pass true to sort all descendant nodes recursively
*/
sortChildren(cmp = nodeTitleSorter, deep = false) {
this.root.sortChildren(cmp, deep);
}
/**
* Convenience method to implement column sorting.
* @see {@link WunderbaumNode.sortByProperty}.
* @since 0.11.0
*/
sortByProperty(options) {
this.root.sortByProperty(options);
}
/** Convert tree to an array of plain objects.
*
* @param callback is called for every node, in order to allow
* modifications.
* Return `false` to ignore this node or `"skip"` to include this node
* without its children.
* @see {@link WunderbaumNode.toDict}.
*/
toDictArray(callback) {
var _a;
const res = this.root.toDict(true, callback);
return (_a = res.children) !== null && _a !== void 0 ? _a : [];
}
/**
* Update column headers and column width.
* Return true if at least one column width changed.
*/
// _updateColumnWidths(options?: UpdateColumnsOptions): boolean {
_updateColumnWidths() {
// options = Object.assign({ updateRows: true, renderMarkup: false }, options);
const defaultMinWidth = 4;
const vpWidth = this.element.clientWidth;
// Shorten last column width to avoid h-scrollbar
// (otherwise resizbing the demo would display a void scrollbar?)
const FIX_ADJUST_LAST_COL = 1;
const columns = this.columns;
const col0 = columns[0];
let totalWidth = 0;
let totalWeight = 0;
let fixedWidth = 0;
let modified = false;
// this.element.classList.toggle("wb-grid", isGrid);
// if (!isGrid && this.isCellNav()) {
// this.setCellNav(false);
// }
// if (options.calculateCols) {
if (col0.id !== "*") {
throw new Error(`First column must have id '*': got '${col0.id}'.`);
}
// Gather width definitions
this._columnsById = {};
for (const col of columns) {
this._columnsById[col.id] = col;
const cw = col.customWidthPx ? `${col.customWidthPx}px` : col.width;
if (col.id === "*" && col !== col0) {
throw new Error(`Column id '*' must be defined only once: '${col.title}'.`);
}
if (!cw || cw === "*") {
col._weight = 1.0;
totalWeight += 1.0;
}
else if (typeof cw === "number") {
col._weight = cw;
totalWeight += cw;
}
else if (typeof cw === "string" && cw.endsWith("px")) {
col._weight = 0;
const px = parseFloat(cw.slice(0, -2));
if (col._widthPx != px) {
modified = true;
col._widthPx = px;
}
fixedWidth += px;
}
else {
error(`Invalid column width: ${cw} (expected string ending with 'px' or number, e.g. "<num>px" or <int>).`);
}
}
// Share remaining space between non-fixed columns
const restPx = Math.max(0, vpWidth - fixedWidth);
let ofsPx = 0;
for (const col of columns) {
let minWidth;
if (col._weight) {
const cmw = col.minWidth;
if (typeof cmw === "number") {
minWidth = cmw;
}
else if (typeof cmw === "string" && cmw.endsWith("px")) {
minWidth = parseFloat(cmw.slice(0, -2));
}
else {
minWidth = defaultMinWidth;
}
const px = Math.max(minWidth, (restPx * col._weight) / totalWeight);
if (col._widthPx != px) {
modified = true;
col._widthPx = px;
}
}
col._ofsPx = ofsPx;
ofsPx += col._widthPx;
}
columns[columns.length - 1]._widthPx -= FIX_ADJUST_LAST_COL;
totalWidth = ofsPx - FIX_ADJUST_LAST_COL;
const tw = `${totalWidth}px`;
this.headerElement.style.width = tw;
this.listContainerElement.style.width = tw;
// }
// Every column has now a calculated `_ofsPx` and `_widthPx`
// this.logInfo("UC", this.columns, vpWidth, this.element.clientWidth, this.element);
// console.trace();
// util.error("BREAK");
// if (modified) {
// this._renderHeaderMarkup();
// if (options.renderMarkup) {
// this.update(ChangeType.header, { removeMarkup: true });
// } else if (options.updateRows) {
// this._updateRows();
// }
// }
return modified;
}
// protected _insertIcon(icon: string, elem: HTMLElement) {
// const iconElem = document.createElement("i");
// iconElem.className = icon;
// elem.appendChild(iconElem);
// }
/** Create/update header markup from `this.columns` definition.
* @internal
*/
_renderHeaderMarkup() {
assert(this.headerElement, "Expected a headerElement");
const wantHeader = this.hasHeader();
setElemDisplay(this.headerElement, wantHeader);
if (!wantHeader) {
return;
}
const iconMap = this.iconMap;
const colCount = this.columns.length;
const headerRow = this.headerElement.querySelector(".wb-row");
assert(headerRow, "Expected a row in header element");
headerRow.innerHTML = "<span class='wb-col'></span>".repeat(colCount);
for (let i = 0; i < colCount; i++) {
const col = this.columns[i];
const colElem = headerRow.children[i];
colElem.style.left = col._ofsPx + "px";
colElem.style.width = col._widthPx + "px";
// Add classes from `columns` definition to `<div.wb-col>` cells
if (typeof col.headerClasses === "string") {
col.headerClasses
? colElem.classList.add(...col.headerClasses.split(" "))
: 0;
}
else {
col.classes ? colElem.classList.add(...col.classes.split(" ")) : 0;
}
// Add tooltip to column title
let tooltip = "";
if (col.tooltip) {
tooltip = escapeTooltip(col.tooltip);
tooltip = ` title="${tooltip}"`;
}
// Add column header icons
let addMarkup = "";
// NOTE: we use CSS float: right to align icons, so they must be added in
// reverse order
if (toBool(col.menu, this.options.columnsMenu, false)) {
const iconClass = "wb-col-icon-menu " + iconMap.colMenu;
const icon = `<i data-command=menu class="wb-col-icon ${iconClass}"></i>`;
addMarkup += icon;
}
if (toBool(col.sortable, this.options.columnsSortable, false)) {
let iconClass = "wb-col-icon-sort " + iconMap.colSortable;
if (col.sortOrder) {
iconClass += `wb-col-sort-${col.sortOrder}`;
iconClass +=
col.sortOrder === "asc" ? iconMap.colSortAsc : iconMap.colSortDesc;
}
const icon = `<i data-command=sort class="wb-col-icon ${iconClass}"></i>`;
addMarkup += icon;
}
if (toBool(col.filterable, this.options.columnsFilterable, false)) {
colElem.classList.toggle("wb-col-filter", !!col.filterActive);
let iconClass = "wb-col-icon-filter " + iconMap.colFilter;
if (col.filterActive) {
iconClass += iconMap.colFilterActive;
}
const icon = `<i data-command=filter class="wb-col-icon ${iconClass}"></i>`;
addMarkup += icon;
}
// Add resizer to all but the last column
if (i < colCount - 1) {
if (toBool(col.resizable, this.options.columnsResizable, false)) {
addMarkup +=
'<span class="wb-col-resizer wb-col-resizer-active"></span>';
}
else {
addMarkup += '<span class="wb-col-resizer"></span>';
}
}
// Create column header
const title = escapeHtml(col.title || col.id);
colElem.innerHTML = `<span class="wb-col-title"${tooltip}>${title}</span>${addMarkup}`;
// Highlight active column
if (this.isCellNav()) {
colElem.classList.toggle("wb-active", i === this.activeColIdx);
}
}
}
/**
* Render pending changes that were scheduled using {@link WunderbaumNode.update} if any.
*
* This is hardly ever neccessary, since we normally either
* - call `update(ChangeType.TYPE)` (async, throttled), or
* - call `update(ChangeType.TYPE, {immediate: true})` (synchronous)
*
* `updatePendingModifications()` will only force immediate execution of
* pending async changes if any.
*/
updatePendingModifications() {
if (this.pendingChangeTypes.size > 0) {
this._updateViewportImmediately();
}
}
/** @internal */
_createNodeIcon(node, showLoading, showBadge) {
const iconMap = this.iconMap;
let iconElem;
let icon = node.getOption("icon");
if (node._errorInfo) {
icon = iconMap.error;
}
else if (node._isLoading && showLoading) {
// Status nodes, or nodes without expander (< minExpandLevel) should
// display the 'loading' status with the i.wb-icon span
icon = iconMap.loading;
}
if (icon === false) {
return null; // explicitly disabled: don't try default icons
}
if (typeof icon === "string") ;
else if (node.statusNodeType) {
icon = iconMap[node.statusNodeType];
}
else if (node.expanded) {
icon = iconMap.folderOpen;
}
else if (node.children) {
icon = iconMap.folder;
}
else if (node.lazy) {
icon = iconMap.folderLazy;
}
else {
icon = iconMap.doc;
}
if (!icon) {
iconElem = document.createElement("i");
iconElem.className = "wb-icon";
}
else if (icon.indexOf("<") >= 0) {
// HTML
iconElem = elemFromHtml(icon);
}
else if (TEST_IMG.test(icon)) {
// Image URL
iconElem = elemFromHtml(`<i class="wb-icon" style="background-image: url('${icon}');">`);
}
else {
// Class name
iconElem = document.createElement("i");
iconElem.className = "wb-icon " + icon;
}
// Event handler `tree.iconBadge` can return a badge text or HTMLSpanElement
const cbRes = showBadge && node._callEvent("iconBadge", { iconSpan: iconElem });
let badge = null;
if (cbRes != null && cbRes !== false) {
let classes = "";
let tooltip = "";
if (isPlainObject(cbRes)) {
badge = "" + cbRes.badge;
classes = cbRes.badgeClass ? " " + cbRes.badgeClass : "";
tooltip = cbRes.badgeTooltip ? ` title="${cbRes.badgeTooltip}"` : "";
}
else if (typeof cbRes === "number") {
badge = "" + cbRes;
}
else {
badge = cbRes; // string or HTMLSpanElement
}
if (typeof badge === "string") {
badge = elemFromHtml(`<span class="wb-badge${classes}"${tooltip}>${escapeHtml(badge)}</span>`);
}
if (badge) {
iconElem.append(badge);
}
}
return iconElem;
}
_updateTopBreadcrumb() {
const breadcrumb = this.breadcrumb;
const topmost = this.getTopmostVpNode(true);
const parentList = topmost === null || topmost === void 0 ? void 0 : topmost.getParentList(false, false);
if (parentList === null || parentList === void 0 ? void 0 : parentList.length) {
breadcrumb.innerHTML = "";
for (const n of topmost.getParentList(false, false)) {
const icon = this._createNodeIcon(n, false, false);
if (icon) {
breadcrumb.append(icon, " ");
}
const part = document.createElement("a");
part.textContent = n.title;
part.href = "#";
part.classList.add("wb-breadcrumb");
part.dataset.key = n.key;
breadcrumb.append(part, this.options.strings.breadcrumbDelimiter);
}
}
else {
breadcrumb.innerHTML = " ";
}
}
/**
* This is the actual update method, which is wrapped inside a throttle method.
* It calls `updateColumns()` and `_updateRows()`.
*
* This protected method should not be called directly but via
* {@link WunderbaumNode.update}`, {@link Wunderbaum.update},
* or {@link Wunderbaum.updatePendingModifications}.
* @internal
*/
_updateViewportImmediately() {
if (this._disableUpdateCount) {
this.log(`_updateViewportImmediately() IGNORED (disable level: ${this._disableUpdateCount}).`);
this._disableUpdateIgnoreCount++;
return;
}
if (this._updateViewportThrottled.pending()) {
// this.logWarn(`_updateViewportImmediately() cancel pending timer.`);
this._updateViewportThrottled.cancel();
}
// Shorten container height to avoid v-scrollbar
const FIX_ADJUST_HEIGHT = 1;
const RF = RenderFlag;
const pending = new Set(this.pendingChangeTypes);
this.pendingChangeTypes.clear();
const scrollOnly = pending.has(RF.scroll) && pending.size === 1;
if (scrollOnly) {
this._updateRows({ newNodesOnly: true });
// this.log("_updateViewportImmediately(): scroll only.");
}
else {
this.log("_updateViewportImmediately():", pending);
if (this.options.adjustHeight !== false) {
let height = this.listContainerElement.clientHeight;
const headerHeight = this.headerElement.clientHeight; // May be 0
const wantHeight = this.element.clientHeight - headerHeight - FIX_ADJUST_HEIGHT;
if (Math.abs(height - wantHeight) > 1.0) {
// this.log("resize", height, wantHeight);
this.listContainerElement.style.height = wantHeight + "px";
height = wantHeight;
}
}
// console.profile(`_updateViewportImmediately()`)
if (pending.has(RF.clearMarkup)) {
this.visit((n) => {
n.removeMarkup();
});
}
// let widthModified = false;
if (pending.has(RF.header)) {
// widthModified = this._updateColumnWidths();
this._updateColumnWidths();
this._renderHeaderMarkup();
}
this._updateRows();
// console.profileEnd(`_updateViewportImmediately()`)
}
if (this.breadcrumb) {
this._updateTopBreadcrumb();
}
this._callEvent("update");
}
// /**
// * Assert that TR order matches the natural node order
// * @internal
// */
// protected _validateRows(): boolean {
// let trs = this.nodeListElement.childNodes;
// let i = 0;
// let prev = -1;
// let ok = true;
// trs.forEach((element) => {
// const tr = element as HTMLTableRowElement;
// const top = Number.parseInt(tr.style.top);
// const n = (<any>tr)._wb_node;
// // if (i < 4) {
// // console.info(
// // `TR#${i}, rowIdx=${n._rowIdx} , top=${top}px: '${n.title}'`
// // );
// // }
// if (prev >= 0 && top !== prev + ROW_HEIGHT) {
// n.logWarn(
// `TR order mismatch at index ${i}: top=${top}px != ${
// prev + ROW_HEIGHT
// }`
// );
// // throw new Error("fault");
// ok = false;
// }
// prev = top;
// i++;
// });
// return ok;
// }
/*
* - Traverse all *visible* nodes of the whole tree, i.e. skip collapsed nodes.
* - Store count of rows to `tree.treeRowCount`.
* - Renumber `node._rowIdx` for all visible nodes.
* - Calculate the index range that must be rendered to fill the viewport
* (including upper and lower prefetch)
* -
*/
_updateRows(options) {
// const label = this.logTime("_updateRows");
// this.log("_updateRows", opts)
options = Object.assign({ newNodesOnly: false }, options);
const newNodesOnly = !!options.newNodesOnly;
const rowHeight = this.options.rowHeightPx;
const vpHeight = this.element.clientHeight;
const prefetch = RENDER_MAX_PREFETCH;
// const grace_prefetch = RENDER_MAX_PREFETCH - RENDER_MIN_PREFETCH;
const ofs = this.element.scrollTop;
let startIdx = Math.max(0, ofs / rowHeight - prefetch);
startIdx = Math.floor(startIdx);
// Make sure start is always even, so the alternating row colors don't
// change when scrolling:
if (startIdx % 2) {
startIdx--;
}
let endIdx = Math.max(0, (ofs + vpHeight) / rowHeight + prefetch);
endIdx = Math.ceil(endIdx);
// this.debug("render", opts);
const obsoleteNodes = new Set();
this.nodeListElement.childNodes.forEach((elem) => {
if (elem._wb_node) {
obsoleteNodes.add(elem._wb_node);
}
});
let idx = 0;
let top = 0;
let modified = false;
let prevElem = "first";
this.visitRows(function (node) {
// node.log("visit")
const rowDiv = node._rowElem;
// Renumber all expanded nodes
if (node._rowIdx !== idx) {
node._rowIdx = idx;
modified = true;
}
if (idx < startIdx || idx > endIdx) {
// row is outside viewport bounds
if (rowDiv) {
prevElem = rowDiv;
}
}
else if (rowDiv && newNodesOnly) {
obsoleteNodes.delete(node);
// no need to update existing node markup
rowDiv.style.top = idx * rowHeight + "px";
prevElem = rowDiv;
}
else {
obsoleteNodes.delete(node);
// Create new markup
if (rowDiv) {
rowDiv.style.top = idx * rowHeight + "px";
}
node._render({ top: top, after: prevElem });
// node.log("render", top, prevElem, "=>", node._rowElem);
prevElem = node._rowElem;
}
idx++;
top += rowHeight;
});
this.treeRowCount = idx;
for (const n of obsoleteNodes) {
n._callEvent("discard");
n.removeMarkup();
}
// Resize tree container
this.nodeListElement.style.height = `${top}px`;
// this.log(
// `_updateRows(scrollOfs:${ofs}, ${startIdx}..${endIdx})`,
// this.nodeListElement.style.height
// );
// this.logTimeEnd(label);
// this._validateRows();
return modified;
}
/**
* Call `callback(node)` for all nodes in hierarchical order (depth-first, pre-order).
* @see {@link IterableIterator<WunderbaumNode>}, {@link WunderbaumNode.visit}.
*
* @param {function} callback the callback function.
* Return false to stop iteration, return "skip" to skip this node and
* children only.
* @returns {boolean} false, if the iterator was stopped.
*/
visit(callback) {
return this.root.visit(callback, false);
}
/**
* Call callback(node) for all nodes in vertical order, top down (or bottom up).
*
* Note that this considers expansion state, i.e. filtered nodes and children
* of collapsed nodes are skipped, unless `includeHidden` is set.
*
* Stop iteration if callback() returns false.<br>
* Return false if iteration was stopped.
*
* @returns {boolean} false if iteration was canceled
*/
visitRows(callback, options) {
if (!this.root.hasChildren()) {
return false;
}
if (options && options.reverse) {
delete options.reverse;
return this._visitRowsUp(callback, options);
}
options = options || {};
let i, nextIdx, parent, res, siblings, stopNode, siblingOfs = 0, skipFirstNode = options.includeSelf === false, node = options.start || this.root.children[0];
const includeHidden = !!options.includeHidden;
const checkFilter = !includeHidden && this.filterMode === "hide";
parent = node.parent;
while (parent) {
// visit siblings
siblings = parent.children;
nextIdx = siblings.indexOf(node) + siblingOfs;
assert(nextIdx >= 0, `Could not find ${node} in parent's children: ${parent}`);
for (i = nextIdx; i < siblings.length; i++) {
node = siblings[i];
if (node === stopNode) {
return false;
}
if (checkFilter &&
!node.statusNodeType &&
!node.match &&
!node.subMatchCount) {
continue;
}
if (!skipFirstNode && callback(node) === false) {
return false;
}
skipFirstNode = false;
// Dive into node's child nodes
if (node.children &&
node.children.length &&
(includeHidden || node.expanded)) {
res = node.visit((n) => {
if (n === stopNode) {
return false;
}
if (checkFilter && !n.match && !n.subMatchCount) {
return "skip";
}
if (callback(n) === false) {
return false;
}
if (!includeHidden && n.children && !n.expanded) {
return "skip";
}
}, false);
if (res === false) {
return false;
}
}
}
// Visit parent nodes (bottom up)
node = parent;
parent = parent.parent;
siblingOfs = 1; //
if (!parent && options.wrap) {
this.logDebug("visitRows(): wrap around");
assert(options.start, "`wrap` option requires `start`");
stopNode = options.start;
options.wrap = false;
parent = this.root;
siblingOfs = 0;
}
}
return true;
}
/**
* Call fn(node) for all nodes in vertical order, bottom up.
* @internal
*/
_visitRowsUp(callback, options) {
let children, idx, parent, node = options.start || this.root.children[0];
const includeHidden = !!options.includeHidden;
if (options.includeSelf !== false) {
if (callback(node) === false) {
return false;
}
}
while (true) {
parent = node.parent;
children = parent.children;
if (children[0] === node) {
// If this is already the first sibling, goto parent
node = parent;
if (!node.parent) {
break; // first node of the tree
}
children = parent.children;
}
else {
// Otherwise, goto prev. sibling
idx = children.indexOf(node);
node = children[idx - 1];
// If the prev. sibling has children, follow down to last descendant
while ((includeHidden || node.expanded) &&
node.children &&
node.children.length) {
children = node.children;
parent = node;
node = children[children.length - 1];
}
}
// Skip invisible
if (!includeHidden && !node.isVisible()) {
continue;
}
if (callback(node) === false) {
return false;
}
}
return true;
}
/**
* Reload the tree with a new source.
*
* Previous data is cleared. Note that also column- and type defintions may
* be passed with the `source` object.
*/
load(source) {
this.clear();
return this.root.load(source);
}
/**
* Disable render requests during operations that would trigger many updates.
*
* ```js
* try {
* tree.enableUpdate(false);
* // ... (long running operation that would trigger many updates)
* foo();
* // ... NOTE: make sure that async operations have finished, e.g.
* await foo();
* } finally {
* tree.enableUpdate(true);
* }
* ```
*/
enableUpdate(flag) {
/*
5 7 9 20 25 30
1 >-------------------------------------<
2 >--------------------<
3 >--------------------------<
*/
if (flag) {
assert(this._disableUpdateCount > 0, "enableUpdate(true) was called too often");
this._disableUpdateCount--;
// this.logDebug(
// `enableUpdate(${flag}): count -> ${this._disableUpdateCount}...`
// );
if (this._disableUpdateCount === 0) {
this.logDebug(`enableUpdate(): active again. Re-painting to catch up with ${this._disableUpdateIgnoreCount} ignored update requests...`);
this._disableUpdateIgnoreCount = 0;
this.update(ChangeType.any, { immediate: true });
}
}
else {
this._disableUpdateCount++;
// this.logDebug(
// `enableUpdate(${flag}): count -> ${this._disableUpdateCount}...`
// );
// this._disableUpdate = Date.now();
}
// return !flag; // return previous value
}
/* ---------------------------------------------------------------------------
* FILTER
* -------------------------------------------------------------------------*/
/**
* Dim or hide unmatched nodes.
* @param filter a string to match against node titles, or a callback function.
* @param options filter options. Defaults to the `tree.options.filter` settings.
* @returns the number of nodes that match the filter.
* @example
* ```ts
* tree.filterNodes("foo", {mode: 'dim', fuzzy: true});
* // or pass a callback
* tree.filterNodes((node) => { return node.data.foo === true }, {mode: 'hide'});
* ```
*/
filterNodes(filter, options) {
return this.extensions.filter.filterNodes(filter, options);
}
/**
* Return the number of nodes that match the current filter.
* @see {@link Wunderbaum.filterNodes}
* @since 0.9.0
*/
countMatches() {
return this.extensions.filter.countMatches();
}
/**
* Dim or hide whole branches.
* @deprecated Use {@link filterNodes} instead and set `options.matchBranch: true`.
*/
filterBranches(filter, options) {
return this.extensions.filter.filterBranches(filter, options);
}
/**
* Reset the filter.
*/
clearFilter() {
return this.extensions.filter.clearFilter();
}
/**
* Return true if a filter is currently applied.
*/
isFilterActive() {
return !!this.filterMode;
}
/**
* Re-apply current filter.
*/
updateFilter() {
return this.extensions.filter.updateFilter();
}
}
Wunderbaum.sequence = 0;
/** Wunderbaum release version number "MAJOR.MINOR.PATCH". */
Wunderbaum.version = "v0.13.0"; // Set to semver by 'grunt release'
/** Expose some useful methods of the util.ts module as `Wunderbaum.util`. */
Wunderbaum.util = util;
export { Wunderbaum };