UNPKG

wunderbaum

Version:

JavaScript tree/grid/treegrid control.

439 lines (409 loc) 14.9 kB
/*! * Wunderbaum - common * Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license. * @VERSION, @DATE (https://github.com/mar10/wunderbaum) */ import { ApplyCommandType, NavigationType, SourceListType, SourceObjectType, IconMapType, MatcherCallback, } from "./types"; import * as util from "./util"; import { WunderbaumNode } from "./wb_node"; export const DEFAULT_DEBUGLEVEL = 4; // Replaced by rollup script /** * Fixed height of a row in pixel. Must match the SCSS variable `$row-outer-height`. */ export const DEFAULT_ROW_HEIGHT = 22; /** * Fixed width of node icons in pixel. Must match the SCSS variable `$icon-outer-width`. */ export const ICON_WIDTH = 20; /** * Adjust the width of the title span, so overflow ellipsis work. * (2 x `$col-padding-x` + 3px rounding errors). */ export const TITLE_SPAN_PAD_Y = 7; /** Render row markup for N nodes above and below the visible viewport. */ export const RENDER_MAX_PREFETCH = 5; /** Skip rendering new rows when we have at least N nodes rendeed above and below the viewport. */ export const RENDER_MIN_PREFETCH = 5; /** Minimum column width if not set otherwise. */ export 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 '/'. */ export 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} * */ export const iconMaps: { [key: string]: IconMapType } = { 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", }, }; export const KEY_NODATA = "__not_found__"; /** Define which keys are handled by embedded <input> control, and should * *not* be passed to tree navigation handler in cell-edit mode. */ export const INPUT_KEYS: { [key: string]: Array<string> } = { text: ["left", "right", "home", "end", "backspace"], number: ["up", "down", "left", "right", "home", "end", "backspace"], checkbox: [], link: [], radiobutton: ["up", "down"], "select-one": ["up", "down"], "select-multiple": ["up", "down"], }; /** Dict keys that are evaluated by source loader (others are added to `tree.data` instead). */ export const RESERVED_TREE_SOURCE_KEYS: Set<string> = 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. */ export const KEY_TO_NAVIGATION_MAP: { [key: string]: NavigationType } = { 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", }; /** Map `KeyEvent.key` to navigation action. */ export const KEY_TO_COMMAND_MAP: { [key: string]: ApplyCommandType } = { " ": "toggleSelect", "+": "expand", Add: "expand", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", Backspace: "parent", "/": "collapseAll", Divide: "collapseAll", End: "lastCol", Home: "firstCol", "Control+End": "last", "Control+Home": "first", "Meta+ArrowDown": "last", // macOs "Meta+ArrowUp": "first", // macOs "*": "expandAll", Multiply: "expandAll", PageDown: "pageDown", PageUp: "pageUp", "-": "collapse", Subtract: "collapse", }; /** Return a callback that returns true if the node title matches the string * or regular expression. * @see {@link WunderbaumNode.findAll} */ export function makeNodeTitleMatcher(match: string | RegExp): MatcherCallback { if (match instanceof RegExp) { return function (node: WunderbaumNode) { return (<RegExp>match).test(node.title); }; } util.assert( typeof match === "string", `Expected a string or RegExp: ${match}` ); // s = escapeRegex(s.toLowerCase()); return function (node: WunderbaumNode) { 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). */ export function makeNodeTitleStartMatcher(s: string): MatcherCallback { s = util.escapeRegex(s); const reMatch = new RegExp("^" + s, "i"); return function (node: WunderbaumNode) { return reMatch.test(node.title); }; } /** Compare two nodes by title (case-insensitive). */ export function nodeTitleSorter(a: WunderbaumNode, b: WunderbaumNode): number { 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: SourceObjectType): void { 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: string) => longToShort[e] ?? e); const newChildren: SourceListType = []; const keyToNodeMap: { [key: string]: number } = {}; const indexToNodeMap: { [key: number]: any } = {}; const keyAttrName = longToShort["key"] ?? "key"; const childrenAttrName = longToShort["children"] ?? "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] = <any>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: string, positionalIdx: number) => { 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) { // top-level node } 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) { 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 */ export function decompressSourceData(source: SourceObjectType): void { let { _format, _version = 1, _keyMap, _valueMap } = source; util.assert(_version === 1, `Expected file version 1 instead of ${_version}`); let longToShort = _keyMap; let shortToLong: { [key: string]: string } = {}; 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 ((<any>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: (<any>source)._typeList }; delete (<any>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: SourceListType) { 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: any = 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); } }