wunderbaum
Version:
JavaScript tree/grid/treegrid control.
935 lines (863 loc) • 25.9 kB
text/typescript
/*!
* Wunderbaum - util
* Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license.
* @VERSION, @DATE (https://github.com/mar10/wunderbaum)
*/
/** @module util */
import { DebouncedFunction, debounce, throttle } from "./debounce";
export { debounce, throttle };
/** Readable names for `MouseEvent.button` */
export const MOUSE_BUTTONS: { [key: number]: string } = {
0: "",
1: "left",
2: "middle",
3: "right",
4: "back",
5: "forward",
};
export const MAX_INT = 9007199254740991;
const userInfo = _getUserInfo();
/**True if the client is using a macOS platform. */
export const isMac = userInfo.isMac;
const REX_HTML = /[&<>"'/]/g; // Escape those characters
const REX_TOOLTIP = /[<>"'/]/g; // Don't escape `&` in tooltips
const ENTITY_MAP: { [key: string]: string } = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
"/": "/",
};
export type FunctionType = (...args: any[]) => any;
export type EventCallbackType = (e: Event) => boolean | void;
type PromiseCallbackType = (val: any) => void;
/** 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.
*/
export class ValidationError extends Error {
constructor(message: string) {
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.
*/
export class Deferred {
private thens: PromiseCallbackType[] = [];
private catches: PromiseCallbackType[] = [];
private status = "";
private resolvedValue: any;
private rejectedError: any;
constructor() {}
resolve(value?: any) {
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?: any) {
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: any) {
if (status === "resolved") {
cb(this.resolvedValue);
} else {
this.thens.unshift(cb);
}
}
catch(cb: any) {
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. */
export function assert(cond: any, msg: string) {
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. */
export function documentReady(callback: () => void): void {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", callback);
} else {
callback();
}
}
/** Resolve when document was loaded. */
export function documentReadyPromise(): Promise<void> {
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.
*/
export function each(
obj: any,
callback: (index: number | string, item: any) => void | boolean
): any {
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)`.*/
export function error(msg: string) {
throw new Error(msg);
}
/** Convert `<`, `>`, `&`, `"`, `'`, and `/` to the equivalent entities. */
export function escapeHtml(s: string): string {
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. `"$"` -> `"\$"`) */
export function escapeRegex(s: string) {
return ("" + s).replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1");
}
/** Convert `<`, `>`, `"`, `'`, and `/` (but not `&`) to the equivalent entities. */
export function escapeTooltip(s: string): string {
return ("" + s).replace(REX_TOOLTIP, function (s) {
return ENTITY_MAP[s];
});
}
/** TODO */
export function extractHtmlText(s: string) {
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
*/
export function getValueFromElem(elem: HTMLElement, coerce = false): any {
const tag = elem.tagName;
let value = null;
if (tag === "SPAN" && elem.classList.contains("wb-col")) {
const span = <HTMLSpanElement>elem;
const embeddedInput = span.querySelector<HTMLElement>("input,select");
if (embeddedInput) {
return getValueFromElem(embeddedInput, coerce);
}
span.innerText = "" + value;
} else if (tag === "INPUT") {
const input = <HTMLInputElement>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<HTMLInputElement>(
`input[name="${name}"]:checked`
);
value = checked ? checked.value : undefined;
}
break;
case "text":
default:
value = input.value;
}
} else if (tag === "SELECT") {
const select = <HTMLSelectElement>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.
*/
export function setValueToElem(elem: HTMLElement, value: any): void {
const tag = elem.tagName;
if (tag === "SPAN" && elem.classList.contains("wb-col")) {
const span = <HTMLSpanElement>elem;
const embeddedInput = span.querySelector("input,select");
if (embeddedInput) {
return setValueToElem(<HTMLElement>embeddedInput, value);
}
// No embedded input: simply write as escaped html
span.innerText = "" + value;
} else if (tag === "INPUT") {
const input = <HTMLInputElement>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 ?? "";
}
} else if (tag === "SELECT") {
const select = <HTMLSelectElement>elem;
if (value == null) {
select.selectedIndex = -1;
} else {
select.value = value;
}
}
}
/** Show/hide element by setting the `display` style to 'none'. */
export function setElemDisplay(
elem: string | HTMLElement,
flag: boolean
): void {
const style = (<HTMLElement>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. */
export function elemFromHtml<T = HTMLElement>(html: string): T {
const t = document.createElement("template");
t.innerHTML = html.trim();
return t.content.firstElementChild as T;
}
const _IGNORE_KEYS = new Set(["Alt", "Control", "Meta", "Shift"]);
/** Return a HtmlElement from selector or cast an existing element. */
export function elemFromSelector<T = HTMLElement>(obj: string | T): T | null {
if (!obj) {
return null; //(null as unknown) as HTMLElement;
}
if (typeof obj === "string") {
return document.querySelector(obj) as T;
}
return obj as T;
}
/**
* 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;
* }
* ```
*/
export function eventToString(event: Event): string {
const key = (<KeyboardEvent>event).key;
const et = event.type;
const s = [];
if ((<KeyboardEvent>event).altKey) {
s.push("Alt");
}
if ((<KeyboardEvent>event).ctrlKey) {
s.push("Control");
}
if ((<KeyboardEvent>event).metaKey) {
s.push("Meta");
}
if ((<KeyboardEvent>event).shiftKey) {
s.push("Shift");
}
if (et === "click" || et === "dblclick") {
s.push(MOUSE_BUTTONS[(<MouseEvent>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
export function extend(...args: any[]) {
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`. */
export function isArray(obj: any) {
return Array.isArray(obj);
}
/** Return true if `obj` is of type `Object` and has no properties. */
export function isEmptyObject(obj: any) {
return Object.keys(obj).length === 0 && obj.constructor === Object;
}
/** Return true if `obj` is of type `function`. */
export function isFunction(obj: any) {
return typeof obj === "function";
}
/** Return true if `obj` is of type `Object`. */
export function isPlainObject(obj: any) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
/** A dummy function that does nothing ('no operation'). */
export function noop(...args: any[]): any {}
/**
* Bind one or more event handlers directly to an [EventTarget](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget).
*
* @param rootTarget EventTarget or selector
* @param eventNames
* @param handler
*/
export function onEvent(
rootTarget: EventTarget | string,
eventNames: string,
handler: EventCallbackType
): void;
/**
* Bind one or more event handlers using event delegation.
*
* E.g. handle all 'input' events for input and textarea elements of a given
* form:
* ```ts
* onEvent("#form_1", "input", "input,textarea", function (e: Event) {
* console.log(e.type, e.target);
* });
* ```
*
* @param rootTarget EventTarget or selector
* @param eventNames
* @param selector
* @param handler
*/
export function onEvent(
rootTarget: EventTarget | string,
eventNames: string,
selector: string,
handler: EventCallbackType
): void;
export function onEvent(
rootTarget: EventTarget | string,
eventNames: string,
selectorOrHandler: string | EventCallbackType,
handlerOrNone?: EventCallbackType
): void {
let selector: string | null, handler: EventCallbackType;
rootTarget = elemFromSelector<EventTarget>(rootTarget)!;
// rootTarget = eventTargetFromSelector<EventTarget>(rootTarget)!;
if (handlerOrNone) {
selector = selectorOrHandler as string;
handler = handlerOrNone!;
} else {
selector = "";
handler = selectorOrHandler as EventCallbackType;
}
eventNames.split(" ").forEach((evn) => {
(<EventTarget>rootTarget).addEventListener(evn, function (e) {
if (!selector) {
return handler!(e); // no event delegation
} else if (e.target) {
let elem = e.target as HTMLElement;
if (elem.matches(selector as string)) {
return handler!(e);
}
elem = elem.closest(selector) as HTMLElement;
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;
});
```
*/
export function overrideMethod(
instance: any,
methodName: string,
handler: FunctionType,
ctx?: any
) {
let prevSuper: FunctionType, prevSuperApply: FunctionType;
const self = ctx || instance;
const prevFunc = instance[methodName];
const _super = (...args: any[]) => {
return prevFunc.apply(self, args);
};
const _superApply = (argsArray: any[]) => {
return prevFunc.apply(self, argsArray);
};
const wrapper = (...args: any[]) => {
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. */
export function setTimeoutPromise<T = unknown>(
this: unknown,
callback: (...args: any[]) => T,
ms: number
) {
return new Promise<T>((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
*/
export async function sleep(ms: number) {
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' -> ...
*/
export function toggleCheckbox(
element: HTMLElement | string,
value?: boolean | null,
tristate?: boolean
): void {
const input = elemFromSelector(element) as HTMLInputElement;
assert(input.type === "checkbox", `Expected a checkbox: ${input.type}`);
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
*/
export function getOption(
opts: any,
name: string,
defaultValue: any = undefined
): any {
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 ?? defaultValue;
}
/** Return the next value from a list of values (rotating). @since 0.11 */
export function rotate(value: any, values: any[]): any {
const idx = values.indexOf(value);
return values[(idx + 1) % values.length];
}
/** Convert an Array or space-separated string to a Set. */
export function toSet(val: any): Set<string> {
if (val instanceof Set) {
return val;
}
if (typeof val === "string") {
const set = new Set<string>();
for (const c of val.split(" ")) {
set.add(c.trim());
}
return set;
}
if (Array.isArray(val)) {
return new Set<string>(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
* ```
*/
export function toPixel(
...defaults: (string | number | undefined | null)[]
): number {
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
* ```
*/
export function toBool(
...boolDefaults: (boolean | undefined | null)[]
): boolean {
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'.
*/
export function intToBool(
val: boolean | number | string | undefined
): boolean | string | undefined {
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', ...). */
export function type(obj: any): string {
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();
* ```
*/
export function adaptiveThrottle(
this: unknown,
callback: (...args: any[]) => void,
options: object
): DebouncedFunction<(...args: any[]) => void> {
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: any[] | null = null;
let pendingTimer: number | null = null;
const throttledFn = (...args: any[]) => {
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 = <number>(<unknown>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;
}