UNPKG

wunderbaum

Version:

JavaScript tree/grid/treegrid control.

1,446 lines (1,441 loc) 322 kB
/*! * 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;", "/": "&#x2F;", }; /** 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),