wunderbaum 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wunderbaum.ts CHANGED
@@ -16,6 +16,7 @@ import { FilterExtension } from "./wb_ext_filter";
16
16
  import { KeynavExtension } from "./wb_ext_keynav";
17
17
  import { LoggerExtension } from "./wb_ext_logger";
18
18
  import { DndExtension } from "./wb_ext_dnd";
19
+ import { GridExtension } from "./wb_ext_grid";
19
20
  import { ExtensionsDict, WunderbaumExtension } from "./wb_extension_base";
20
21
 
21
22
  import {
@@ -34,13 +35,13 @@ import {
34
35
  } from "./common";
35
36
  import { WunderbaumNode } from "./wb_node";
36
37
  import { Deferred } from "./deferred";
37
- import { DebouncedFunction, throttle } from "./debounce";
38
+ // import { DebouncedFunction, throttle } from "./debounce";
38
39
  import { EditExtension } from "./wb_ext_edit";
39
40
  import { WunderbaumOptions } from "./wb_options";
40
41
 
41
42
  // const class_prefix = "wb-";
42
43
  // const node_props: string[] = ["title", "key", "refKey"];
43
- const MAX_CHANGED_NODES = 10;
44
+ // const MAX_CHANGED_NODES = 10;
44
45
 
45
46
  /**
46
47
  * A persistent plain object or array.
@@ -48,71 +49,81 @@ const MAX_CHANGED_NODES = 10;
48
49
  * See also [[WunderbaumOptions]].
49
50
  */
50
51
  export class Wunderbaum {
51
- static version: string = "@VERSION"; // Set to semver by 'grunt release'
52
- static sequence = 0;
52
+ protected static sequence = 0;
53
53
 
54
- /** The invisible root node, that holds all visible top level nodes. */
55
- readonly root: WunderbaumNode;
56
- readonly id: string;
57
-
58
- readonly element: HTMLDivElement;
59
- // readonly treeElement: HTMLDivElement;
60
- readonly headerElement: HTMLDivElement | null;
61
- readonly scrollContainer: HTMLDivElement;
62
- readonly nodeListElement: HTMLDivElement;
63
- readonly _updateViewportThrottled: DebouncedFunction<(...args: any) => void>;
54
+ /** Wunderbaum release version number "MAJOR.MINOR.PATCH". */
55
+ public static version: string = "@VERSION"; // Set to semver by 'grunt release'
64
56
 
57
+ /** The invisible root node, that holds all visible top level nodes. */
58
+ public readonly root: WunderbaumNode;
59
+ /** Unique tree ID as passed to constructor. Defaults to `"wb_SEQUENCE"`. */
60
+ public readonly id: string;
61
+ /** The `div` container element that was passed to the constructor. */
62
+ public readonly element: HTMLDivElement;
63
+ /** The `div.wb-header` element if any. */
64
+ public readonly headerElement: HTMLDivElement | null;
65
+ /** The `div.wb-scroll-container` element that contains the `nodeListElement`. */
66
+ public readonly scrollContainer: HTMLDivElement;
67
+ /** The `div.wb-node-list` element that contains all visible div.wb-row child elements. */
68
+ public readonly nodeListElement: HTMLDivElement;
69
+
70
+ protected readonly _updateViewportThrottled: (...args: any) => void;
65
71
  protected extensionList: WunderbaumExtension[] = [];
66
72
  protected extensions: ExtensionsDict = {};
67
- // protected extensionMap = new Map<string, WunderbaumExtension>();
68
73
 
69
74
  /** Merged options from constructor args and tree- and extension defaults. */
70
75
  public options: WunderbaumOptions;
71
76
 
72
77
  protected keyMap = new Map<string, WunderbaumNode>();
73
78
  protected refKeyMap = new Map<string, Set<WunderbaumNode>>();
74
- protected viewNodes = new Set<WunderbaumNode>();
75
- // protected rows: WunderbaumNode[] = [];
76
- // protected _rowCount = 0;
79
+ // protected viewNodes = new Set<WunderbaumNode>();
80
+ protected treeRowCount = 0;
81
+ protected _disableUpdateCount = 0;
77
82
 
78
83
  // protected eventHandlers : Array<function> = [];
79
84
 
85
+ /** Currently active node if any. */
80
86
  public activeNode: WunderbaumNode | null = null;
87
+ /** Current node hat has keyboard focus if any. */
81
88
  public focusNode: WunderbaumNode | null = null;
82
- _disableUpdate = 0;
83
- _disableUpdateCount = 0;
84
89
 
85
90
  /** Shared properties, referenced by `node.type`. */
86
91
  public types: { [key: string]: any } = {};
87
92
  /** List of column definitions. */
88
93
  public columns: any[] = [];
89
- public _columnsById: { [key: string]: any } = {};
90
94
 
91
- protected resizeObserver;
95
+ protected _columnsById: { [key: string]: any } = {};
96
+ protected resizeObserver: ResizeObserver;
92
97
 
93
98
  // Modification Status
94
- protected changedSince = 0;
95
- protected changes = new Set<ChangeType>();
96
- protected changedNodes = new Set<WunderbaumNode>();
99
+ // protected changedSince = 0;
100
+ // protected changes = new Set<ChangeType>();
101
+ // protected changedNodes = new Set<WunderbaumNode>();
102
+ protected changeRedrawRequestPending = false;
103
+
104
+ /** A Promise that is resolved when the tree was initialized (similar to `init(e)` event). */
105
+ public readonly ready: Promise<any>;
106
+ /** Expose some useful methods of the util.ts module as `Wunderbaum.util`. */
107
+ public static util = util;
108
+ /** Expose some useful methods of the util.ts module as `tree._util`. */
109
+ public _util = util;
97
110
 
98
111
  // --- FILTER ---
99
112
  public filterMode: FilterModeType = null;
100
113
 
101
114
  // --- KEYNAV ---
115
+ /** @internal Use `setColumn()`/`getActiveColElem()`*/
102
116
  public activeColIdx = 0;
117
+ /** @internal */
103
118
  public navMode = NavigationMode.row;
119
+ /** @internal */
104
120
  public lastQuicksearchTime = 0;
121
+ /** @internal */
105
122
  public lastQuicksearchTerm = "";
106
123
 
107
124
  // --- EDIT ---
108
125
  protected lastClickTime = 0;
109
126
 
110
- readonly ready: Promise<any>;
111
-
112
- public static util = util;
113
- // TODO: make accessible in compiled JS like this?
114
- public _util = util;
115
-
116
127
  constructor(options: WunderbaumOptions) {
117
128
  let opts = (this.options = util.extend(
118
129
  {
@@ -125,7 +136,7 @@ export class Wunderbaum {
125
136
  rowHeightPx: ROW_HEIGHT,
126
137
  columns: null,
127
138
  types: null,
128
- escapeTitles: true,
139
+ // escapeTitles: true,
129
140
  showSpinner: false,
130
141
  checkbox: true,
131
142
  minExpandLevel: 0,
@@ -185,6 +196,7 @@ export class Wunderbaum {
185
196
  this._registerExtension(new EditExtension(this));
186
197
  this._registerExtension(new FilterExtension(this));
187
198
  this._registerExtension(new DndExtension(this));
199
+ this._registerExtension(new GridExtension(this));
188
200
  this._registerExtension(new LoggerExtension(this));
189
201
 
190
202
  // --- Evaluate options
@@ -215,12 +227,9 @@ export class Wunderbaum {
215
227
  this.navMode = NavigationMode.cellNav;
216
228
  }
217
229
 
218
- this._updateViewportThrottled = throttle(
219
- () => {
220
- this._updateViewport();
221
- },
222
- opts.updateThrottleWait,
223
- { leading: true, trailing: true }
230
+ this._updateViewportThrottled = util.addaptiveThrottle(
231
+ this._updateViewport.bind(this),
232
+ {}
224
233
  );
225
234
 
226
235
  // --- Create Markup
@@ -313,10 +322,8 @@ export class Wunderbaum {
313
322
  .finally(() => {
314
323
  this.element.querySelector("progress.spinner")?.remove();
315
324
  this.element.classList.remove("wb-initializing");
316
- // this.updateViewport();
317
325
  });
318
326
  } else {
319
- // this.updateViewport();
320
327
  readyDeferred.resolve();
321
328
  }
322
329
 
@@ -328,21 +335,19 @@ export class Wunderbaum {
328
335
 
329
336
  // --- Bind listeners
330
337
  this.scrollContainer.addEventListener("scroll", (e: Event) => {
331
- this.updateViewport();
338
+ this.setModified(ChangeType.vscroll);
332
339
  });
333
340
 
334
- // window.addEventListener("resize", (e: Event) => {
335
- // this.updateViewport();
336
- // });
337
341
  this.resizeObserver = new ResizeObserver((entries) => {
338
- this.updateViewport();
339
- console.log("ResizeObserver: Size changed", entries);
342
+ this.setModified(ChangeType.vscroll);
343
+ // this.log("ResizeObserver: Size changed", entries);
340
344
  });
341
345
  this.resizeObserver.observe(this.element);
342
346
 
343
347
  util.onEvent(this.nodeListElement, "click", "div.wb-row", (e) => {
344
348
  const info = Wunderbaum.getEventInfo(e);
345
349
  const node = info.node;
350
+ // this.log("click", info, e);
346
351
 
347
352
  if (
348
353
  this._callEvent("click", { event: e, node: node, info: info }) === false
@@ -375,8 +380,6 @@ export class Wunderbaum {
375
380
  node.setSelected(!node.isSelected());
376
381
  }
377
382
  }
378
- // if(e.target.classList.)
379
- // this.log("click", info);
380
383
  this.lastClickTime = Date.now();
381
384
  });
382
385
 
@@ -401,40 +404,19 @@ export class Wunderbaum {
401
404
  forceClose: true,
402
405
  });
403
406
  }
404
- // if (flag && !this.activeNode ) {
405
- // setTimeout(() => {
406
- // if (!this.activeNode) {
407
- // const firstNode = this.getFirstChild();
408
- // if (firstNode && !firstNode?.isStatusNode()) {
409
- // firstNode.logInfo("Activate on focus", e);
410
- // firstNode.setActive(true, { event: e });
411
- // }
412
- // }
413
- // }, 10);
414
- // }
415
407
  });
416
408
  }
417
409
 
418
- /** */
419
- // _renderHeader(){
420
- // const coldivs = "<span class='wb-col'></span>".repeat(this.columns.length);
421
- // this.element.innerHTML = `
422
- // <div class='wb-header'>
423
- // <div class='wb-row'>
424
- // ${coldivs}
425
- // </div>
426
- // </div>`;
427
-
428
- // }
429
-
430
- /** Return a Wunderbaum instance, from element, index, or event.
410
+ /**
411
+ * Return a Wunderbaum instance, from element, id, index, or event.
431
412
  *
432
- * @example
433
- * getTree(); // Get first Wunderbaum instance on page
434
- * getTree(1); // Get second Wunderbaum instance on page
435
- * getTree(event); // Get tree for this mouse- or keyboard event
436
- * getTree("foo"); // Get tree for this `tree.options.id`
413
+ * ```js
414
+ * getTree(); // Get first Wunderbaum instance on page
415
+ * getTree(1); // Get second Wunderbaum instance on page
416
+ * getTree(event); // Get tree for this mouse- or keyboard event
417
+ * getTree("foo"); // Get tree for this `tree.options.id`
437
418
  * getTree("#tree"); // Get tree for this matching element
419
+ * ```
438
420
  */
439
421
  public static getTree(
440
422
  el?: Element | Event | number | string | WunderbaumNode
@@ -476,9 +458,8 @@ export class Wunderbaum {
476
458
  return null;
477
459
  }
478
460
 
479
- /** Return a WunderbaumNode instance from element, event.
480
- *
481
- * @param el
461
+ /**
462
+ * Return a WunderbaumNode instance from element or event.
482
463
  */
483
464
  public static getNode(el: Element | Event): WunderbaumNode | null {
484
465
  if (!el) {
@@ -499,7 +480,7 @@ export class Wunderbaum {
499
480
  return null;
500
481
  }
501
482
 
502
- /** */
483
+ /** @internal */
503
484
  protected _registerExtension(extension: WunderbaumExtension): void {
504
485
  this.extensionList.push(extension);
505
486
  this.extensions[extension.id] = extension;
@@ -543,7 +524,7 @@ export class Wunderbaum {
543
524
  (node.tree as any) = null;
544
525
  (node.parent as any) = null;
545
526
  // node.title = "DISPOSED: " + node.title
546
- this.viewNodes.delete(node);
527
+ // this.viewNodes.delete(node);
547
528
  node.removeMarkup();
548
529
  }
549
530
 
@@ -568,7 +549,9 @@ export class Wunderbaum {
568
549
  return res;
569
550
  }
570
551
 
571
- /** Call tree method or extension method if defined.
552
+ /**
553
+ * Call tree method or extension method if defined.
554
+ *
572
555
  * Example:
573
556
  * ```js
574
557
  * tree._callMethod("edit.startEdit", "arg1", "arg2")
@@ -585,37 +568,34 @@ export class Wunderbaum {
585
568
  }
586
569
  }
587
570
 
588
- /** Call event handler if defined in tree.options.
571
+ /**
572
+ * Call event handler if defined in tree or tree.EXTENSION options.
573
+ *
589
574
  * Example:
590
575
  * ```js
591
576
  * tree._callEvent("edit.beforeEdit", {foo: 42})
592
577
  * ```
593
578
  */
594
- _callEvent(name: string, extra?: any): any {
595
- const [p, n] = name.split(".");
579
+ _callEvent(type: string, extra?: any): any {
580
+ const [p, n] = type.split(".");
596
581
  const opts = this.options as any;
597
582
  const func = n ? opts[p][n] : opts[p];
598
583
  if (func) {
599
584
  return func.call(
600
585
  this,
601
- util.extend({ name: name, tree: this, util: this._util }, extra)
586
+ util.extend({ type: type, tree: this, util: this._util }, extra)
602
587
  );
603
588
  // } else {
604
- // this.logError(`Triggering undefined event '${name}'.`)
589
+ // this.logError(`Triggering undefined event '${type}'.`)
605
590
  }
606
591
  }
607
592
 
608
- /** Return the topmost visible node in the viewport */
609
- protected _firstNodeInView(complete = true) {
610
- let topIdx: number, node: WunderbaumNode;
611
- if (complete) {
612
- topIdx = Math.ceil(this.scrollContainer.scrollTop / ROW_HEIGHT);
613
- } else {
614
- topIdx = Math.floor(this.scrollContainer.scrollTop / ROW_HEIGHT);
615
- }
593
+ /** Return the node for given row index. */
594
+ protected _getNodeByRowIdx(idx: number): WunderbaumNode | null {
616
595
  // TODO: start searching from active node (reverse)
596
+ let node: WunderbaumNode | null = null;
617
597
  this.visitRows((n) => {
618
- if (n._rowIdx === topIdx) {
598
+ if (n._rowIdx === idx) {
619
599
  node = n;
620
600
  return false;
621
601
  }
@@ -623,9 +603,24 @@ export class Wunderbaum {
623
603
  return <WunderbaumNode>node!;
624
604
  }
625
605
 
626
- /** Return the lowest visible node in the viewport */
627
- protected _lastNodeInView(complete = true) {
628
- let bottomIdx: number, node: WunderbaumNode;
606
+ /** Return the topmost visible node in the viewport. */
607
+ getTopmostVpNode(complete = true) {
608
+ let topIdx: number;
609
+ const gracePy = 1; // ignore subpixel scrolling
610
+
611
+ if (complete) {
612
+ topIdx = Math.ceil(
613
+ (this.scrollContainer.scrollTop - gracePy) / ROW_HEIGHT
614
+ );
615
+ } else {
616
+ topIdx = Math.floor(this.scrollContainer.scrollTop / ROW_HEIGHT);
617
+ }
618
+ return this._getNodeByRowIdx(topIdx)!;
619
+ }
620
+
621
+ /** Return the lowest visible node in the viewport. */
622
+ getLowestVpNode(complete = true) {
623
+ let bottomIdx: number;
629
624
  if (complete) {
630
625
  bottomIdx =
631
626
  Math.floor(
@@ -639,17 +634,11 @@ export class Wunderbaum {
639
634
  ROW_HEIGHT
640
635
  ) - 1;
641
636
  }
642
- // TODO: start searching from active node
643
- this.visitRows((n) => {
644
- if (n._rowIdx === bottomIdx) {
645
- node = n;
646
- return false;
647
- }
648
- });
649
- return <WunderbaumNode>node!;
637
+ bottomIdx = Math.min(bottomIdx, this.count(true) - 1);
638
+ return this._getNodeByRowIdx(bottomIdx)!;
650
639
  }
651
640
 
652
- /** Return preceeding visible node in the viewport */
641
+ /** Return preceeding visible node in the viewport. */
653
642
  protected _getPrevNodeInView(node?: WunderbaumNode, ofs = 1) {
654
643
  this.visitRows(
655
644
  (n) => {
@@ -663,7 +652,7 @@ export class Wunderbaum {
663
652
  return node;
664
653
  }
665
654
 
666
- /** Return following visible node in the viewport */
655
+ /** Return following visible node in the viewport. */
667
656
  protected _getNextNodeInView(node?: WunderbaumNode, ofs = 1) {
668
657
  this.visitRows(
669
658
  (n) => {
@@ -677,23 +666,27 @@ export class Wunderbaum {
677
666
  return node;
678
667
  }
679
668
 
669
+ /**
670
+ * Append (or insert) a list of toplevel nodes.
671
+ *
672
+ * @see {@link WunderbaumNode.addChildren}
673
+ */
680
674
  addChildren(nodeData: any, options?: any): WunderbaumNode {
681
675
  return this.root.addChildren(nodeData, options);
682
676
  }
683
677
 
684
678
  /**
685
- * Apply a modification (or navigation) operation on the tree or active node.
686
- * @returns
679
+ * Apply a modification (or navigation) operation on the **tree or active node**.
687
680
  */
688
681
  applyCommand(cmd: ApplyCommandType, opts?: any): any;
689
682
 
690
683
  /**
691
- * Apply a modification (or navigation) operation on a node.
692
- * @returns
684
+ * Apply a modification (or navigation) operation on a **node**.
685
+ * @see {@link WunderbaumNode.applyCommand}
693
686
  */
694
687
  applyCommand(cmd: ApplyCommandType, node: WunderbaumNode, opts?: any): any;
695
688
 
696
- /*
689
+ /**
697
690
  * Apply a modification or navigation operation.
698
691
  *
699
692
  * Most of these commands simply map to a node or tree method.
@@ -824,7 +817,8 @@ export class Wunderbaum {
824
817
  this.root.children = null;
825
818
  this.keyMap.clear();
826
819
  this.refKeyMap.clear();
827
- this.viewNodes.clear();
820
+ // this.viewNodes.clear();
821
+ this.treeRowCount = 0;
828
822
  this.activeNode = null;
829
823
  this.focusNode = null;
830
824
 
@@ -833,9 +827,9 @@ export class Wunderbaum {
833
827
  // this._columnsById = {};
834
828
 
835
829
  // Modification Status
836
- this.changedSince = 0;
837
- this.changes.clear();
838
- this.changedNodes.clear();
830
+ // this.changedSince = 0;
831
+ // this.changes.clear();
832
+ // this.changedNodes.clear();
839
833
 
840
834
  // // --- FILTER ---
841
835
  // public filterMode: FilterModeType = null;
@@ -845,7 +839,7 @@ export class Wunderbaum {
845
839
  // public cellNavMode = false;
846
840
  // public lastQuicksearchTime = 0;
847
841
  // public lastQuicksearchTerm = "";
848
- this.updateViewport();
842
+ this.setModified(ChangeType.structure);
849
843
  }
850
844
 
851
845
  /**
@@ -867,10 +861,11 @@ export class Wunderbaum {
867
861
  /**
868
862
  * Return `tree.option.NAME` (also resolving if this is a callback).
869
863
  *
870
- * See also [[WunderbaumNode.getOption()]] to consider `node.NAME` setting and
871
- * `tree.types[node.type].NAME`.
864
+ * See also {@link WunderbaumNode.getOption|WunderbaumNode.getOption()}
865
+ * to consider `node.NAME` setting and `tree.types[node.type].NAME`.
872
866
  *
873
- * @param name option name (use dot notation to access extension option, e.g. `filter.mode`)
867
+ * @param name option name (use dot notation to access extension option, e.g.
868
+ * `filter.mode`)
874
869
  */
875
870
  getOption(name: string, defaultValue?: any): any {
876
871
  let ext;
@@ -919,17 +914,13 @@ export class Wunderbaum {
919
914
 
920
915
  /** Run code, but defer `updateViewport()` until done. */
921
916
  runWithoutUpdate(func: () => any, hint = null): void {
922
- // const prev = this._disableUpdate;
923
- // const start = Date.now();
924
- // this._disableUpdate = Date.now();
925
917
  try {
926
918
  this.enableUpdate(false);
927
- return func();
919
+ const res = func();
920
+ util.assert(!(res instanceof Promise));
921
+ return res;
928
922
  } finally {
929
923
  this.enableUpdate(true);
930
- // if (!prev && this._disableUpdate === start) {
931
- // this._disableUpdate = 0;
932
- // }
933
924
  }
934
925
  }
935
926
 
@@ -946,14 +937,15 @@ export class Wunderbaum {
946
937
  }
947
938
 
948
939
  /** Return the number of nodes in the data model.*/
949
- count(visible = false) {
940
+ count(visible = false): number {
950
941
  if (visible) {
951
- return this.viewNodes.size;
942
+ return this.treeRowCount;
943
+ // return this.viewNodes.size;
952
944
  }
953
945
  return this.keyMap.size;
954
946
  }
955
947
 
956
- /* Internal sanity check. */
948
+ /** @internal sanity check. */
957
949
  _check() {
958
950
  let i = 0;
959
951
  this.visit((n) => {
@@ -965,27 +957,32 @@ export class Wunderbaum {
965
957
  // util.assert(this.keyMap.size === i);
966
958
  }
967
959
 
968
- /**Find all nodes that matches condition.
960
+ /**
961
+ * Find all nodes that matches condition.
969
962
  *
970
963
  * @param match title string to search for, or a
971
964
  * callback function that returns `true` if a node is matched.
972
- * @see [[WunderbaumNode.findAll]]
965
+ *
966
+ * @see {@link WunderbaumNode.findAll}
973
967
  */
974
968
  findAll(match: string | MatcherType) {
975
969
  return this.root.findAll(match);
976
970
  }
977
971
 
978
- /**Find first node that matches condition.
972
+ /**
973
+ * Find first node that matches condition.
979
974
  *
980
975
  * @param match title string to search for, or a
981
976
  * callback function that returns `true` if a node is matched.
982
- * @see [[WunderbaumNode.findFirst]]
977
+ * @see {@link WunderbaumNode.findFirst}
978
+ *
983
979
  */
984
980
  findFirst(match: string | MatcherType) {
985
981
  return this.root.findFirst(match);
986
982
  }
987
983
 
988
- /** Find the next visible node that starts with `match`, starting at `startNode`
984
+ /**
985
+ * Find the next visible node that starts with `match`, starting at `startNode`
989
986
  * and wrap-around at the end.
990
987
  */
991
988
  findNextNode(
@@ -1023,7 +1020,8 @@ export class Wunderbaum {
1023
1020
  return res;
1024
1021
  }
1025
1022
 
1026
- /** Find a node relative to another node.
1023
+ /**
1024
+ * Find a node relative to another node.
1027
1025
  *
1028
1026
  * @param node
1029
1027
  * @param where 'down', 'first', 'last', 'left', 'parent', 'right', or 'up'.
@@ -1033,7 +1031,7 @@ export class Wunderbaum {
1033
1031
  */
1034
1032
  findRelatedNode(node: WunderbaumNode, where: string, includeHidden = false) {
1035
1033
  let res = null;
1036
- let pageSize = Math.floor(this.scrollContainer.clientHeight / ROW_HEIGHT);
1034
+ const pageSize = Math.floor(this.scrollContainer.clientHeight / ROW_HEIGHT);
1037
1035
 
1038
1036
  switch (where) {
1039
1037
  case "parent":
@@ -1090,21 +1088,23 @@ export class Wunderbaum {
1090
1088
  res = this._getNextNodeInView(node);
1091
1089
  break;
1092
1090
  case "pageDown":
1093
- let bottomNode = this._lastNodeInView();
1094
- // this.logDebug(where, this.focusNode, bottomNode);
1091
+ const bottomNode = this.getLowestVpNode();
1092
+ // this.logDebug(`${where}(${node}) -> ${bottomNode}`);
1095
1093
 
1096
- if (this.focusNode !== bottomNode) {
1094
+ if (node._rowIdx! < bottomNode._rowIdx!) {
1097
1095
  res = bottomNode;
1098
1096
  } else {
1099
1097
  res = this._getNextNodeInView(node, pageSize);
1100
1098
  }
1101
1099
  break;
1102
1100
  case "pageUp":
1103
- if (this.focusNode && this.focusNode._rowIdx === 0) {
1104
- res = this.focusNode;
1101
+ if (node._rowIdx === 0) {
1102
+ res = node;
1105
1103
  } else {
1106
- let topNode = this._firstNodeInView();
1107
- if (this.focusNode !== topNode) {
1104
+ const topNode = this.getTopmostVpNode();
1105
+ // this.logDebug(`${where}(${node}) -> ${topNode}`);
1106
+
1107
+ if (node._rowIdx! > topNode._rowIdx!) {
1108
1108
  res = topNode;
1109
1109
  } else {
1110
1110
  res = this._getPrevNodeInView(node, pageSize);
@@ -1118,7 +1118,7 @@ export class Wunderbaum {
1118
1118
  }
1119
1119
 
1120
1120
  /**
1121
- * Return the active cell of the currently active node or null.
1121
+ * Return the active cell (`span.wb-col`) of the currently active node or null.
1122
1122
  */
1123
1123
  getActiveColElem() {
1124
1124
  if (this.activeNode && this.activeColIdx >= 0) {
@@ -1157,9 +1157,11 @@ export class Wunderbaum {
1157
1157
  static getEventInfo(event: Event) {
1158
1158
  let target = <Element>event.target,
1159
1159
  cl = target.classList,
1160
- parentCol = target.closest(".wb-col"),
1160
+ parentCol = target.closest("span.wb-col") as HTMLSpanElement,
1161
1161
  node = Wunderbaum.getNode(target),
1162
+ tree = node ? node.tree : Wunderbaum.getTree(event),
1162
1163
  res = {
1164
+ tree: tree,
1163
1165
  node: node,
1164
1166
  region: NodeRegion.unknown,
1165
1167
  colDef: undefined,
@@ -1189,13 +1191,15 @@ export class Wunderbaum {
1189
1191
  res.colIdx = idx;
1190
1192
  } else {
1191
1193
  // Somewhere near the title
1192
- console.warn("getEventInfo(): not found", event, res);
1194
+ if (event.type !== "mousemove" && !(event instanceof KeyboardEvent)) {
1195
+ console.warn("getEventInfo(): not found", event, res);
1196
+ }
1193
1197
  return res;
1194
1198
  }
1195
1199
  if (res.colIdx === -1) {
1196
1200
  res.colIdx = 0;
1197
1201
  }
1198
- res.colDef = node!.tree.columns[res.colIdx];
1202
+ res.colDef = tree?.columns[res.colIdx];
1199
1203
  res.colDef != null ? (res.colId = (<any>res.colDef).id) : 0;
1200
1204
  // this.log("Event", event, res);
1201
1205
  return res;
@@ -1223,7 +1227,8 @@ export class Wunderbaum {
1223
1227
  return this._callMethod("edit.isEditingTitle");
1224
1228
  }
1225
1229
 
1226
- /** Return true if any node is currently beeing loaded, i.e. a Ajax request is pending.
1230
+ /**
1231
+ * Return true if any node is currently beeing loaded, i.e. a Ajax request is pending.
1227
1232
  */
1228
1233
  isLoading(): boolean {
1229
1234
  var res = false;
@@ -1238,8 +1243,10 @@ export class Wunderbaum {
1238
1243
  return res;
1239
1244
  }
1240
1245
 
1241
- /** Alias for `logDebug` */
1242
- log = this.logDebug; // Alias
1246
+ /** Alias for {@link Wunderbaum.logDebug}.
1247
+ * @alias Wunderbaum.logDebug
1248
+ */
1249
+ log = this.logDebug;
1243
1250
 
1244
1251
  /** Log to console if opts.debugLevel >= 4 */
1245
1252
  logDebug(...args: any[]) {
@@ -1257,7 +1264,7 @@ export class Wunderbaum {
1257
1264
  }
1258
1265
  }
1259
1266
 
1260
- /* Log to console if opts.debugLevel >= 3 */
1267
+ /** Log to console if opts.debugLevel >= 3 */
1261
1268
  logInfo(...args: any[]) {
1262
1269
  if (this.options.debugLevel >= 3) {
1263
1270
  Array.prototype.unshift.call(args, this.toString());
@@ -1288,85 +1295,13 @@ export class Wunderbaum {
1288
1295
  }
1289
1296
  }
1290
1297
 
1291
- /** */
1292
- protected render(opts?: any): boolean {
1293
- const label = this.logTime("render");
1294
- let idx = 0;
1295
- let top = 0;
1296
- const height = ROW_HEIGHT;
1297
- let modified = false;
1298
- let start = opts?.startIdx;
1299
- let end = opts?.endIdx;
1300
- const obsoleteViewNodes = this.viewNodes;
1301
-
1302
- this.viewNodes = new Set();
1303
- let viewNodes = this.viewNodes;
1304
- // this.debug("render", opts);
1305
- util.assert(start != null && end != null);
1306
-
1307
- // Make sure start is always even, so the alternating row colors don't
1308
- // change when scrolling:
1309
- if (start % 2) {
1310
- start--;
1311
- }
1312
-
1313
- this.visitRows(function (node) {
1314
- const prevIdx = node._rowIdx;
1315
-
1316
- viewNodes.add(node);
1317
- obsoleteViewNodes.delete(node);
1318
- if (prevIdx !== idx) {
1319
- node._rowIdx = idx;
1320
- modified = true;
1321
- }
1322
- if (idx < start || idx > end) {
1323
- node._callEvent("discard");
1324
- node.removeMarkup();
1325
- } else {
1326
- // if (!node._rowElem || prevIdx != idx) {
1327
- node.render({ top: top });
1328
- }
1329
- idx++;
1330
- top += height;
1331
- });
1332
- for (const prevNode of obsoleteViewNodes) {
1333
- prevNode._callEvent("discard");
1334
- prevNode.removeMarkup();
1335
- }
1336
- // Resize tree container
1337
- this.nodeListElement.style.height = "" + top + "px";
1338
- // this.log("render()", this.nodeListElement.style.height);
1339
- this.logTimeEnd(label);
1340
- return modified;
1341
- }
1342
-
1343
- /**Recalc and apply header columns from `this.columns`. */
1344
- renderHeader() {
1345
- if (!this.headerElement) {
1346
- return;
1347
- }
1348
- const headerRow = this.headerElement.querySelector(".wb-row")!;
1349
- util.assert(headerRow);
1350
- headerRow.innerHTML = "<span class='wb-col'></span>".repeat(
1351
- this.columns.length
1352
- );
1353
-
1354
- for (let i = 0; i < this.columns.length; i++) {
1355
- let col = this.columns[i];
1356
- let colElem = <HTMLElement>headerRow.children[i];
1357
- colElem.style.left = col._ofsPx + "px";
1358
- colElem.style.width = col._widthPx + "px";
1359
- colElem.textContent = col.title || col.id;
1360
- }
1361
- }
1362
-
1363
1298
  /**
1299
+ * Make sure that this node is scrolled into the viewport.
1364
1300
  *
1365
1301
  * @param {boolean | PlainObject} [effects=false] animation options.
1366
1302
  * @param {object} [options=null] {topNode: null, effects: ..., parent: ...}
1367
1303
  * this node will remain visible in
1368
1304
  * any case, even if `this` is outside the scroll pane.
1369
- * Make sure that a node is scrolled into the viewport.
1370
1305
  */
1371
1306
  scrollTo(opts: any) {
1372
1307
  const MARGIN = 1;
@@ -1388,53 +1323,40 @@ export class Wunderbaum {
1388
1323
  // Node is above viewport
1389
1324
  newTop = nodeOfs + MARGIN;
1390
1325
  }
1391
- this.log("scrollTo(" + nodeOfs + "): " + curTop + " => " + newTop, height);
1392
1326
  if (newTop != null) {
1327
+ this.log(
1328
+ "scrollTo(" + nodeOfs + "): " + curTop + " => " + newTop,
1329
+ height
1330
+ );
1393
1331
  this.scrollContainer.scrollTop = newTop;
1394
- this.updateViewport();
1395
- }
1396
- }
1397
-
1398
- /** */
1399
- setCellMode(mode: NavigationMode) {
1400
- // util.assert(this.cellNavMode);
1401
- // util.assert(0 <= colIdx && colIdx < this.columns.length);
1402
- if (mode === this.navMode) {
1403
- return;
1404
- }
1405
- const prevMode = this.navMode;
1406
- const cellMode = mode !== NavigationMode.row;
1407
-
1408
- this.navMode = mode;
1409
- if (cellMode && prevMode === NavigationMode.row) {
1410
- this.setColumn(0);
1332
+ this.setModified(ChangeType.vscroll);
1411
1333
  }
1412
- this.element.classList.toggle("wb-cell-mode", cellMode);
1413
- this.element.classList.toggle(
1414
- "wb-cell-edit-mode",
1415
- mode === NavigationMode.cellEdit
1416
- );
1417
- this.setModified(ChangeType.row, this.activeNode);
1418
1334
  }
1419
1335
 
1420
- /** */
1336
+ /**
1337
+ * Set column #colIdx to 'active'.
1338
+ *
1339
+ * This higlights the column header and -cells by adding the `wb-active` class.
1340
+ * Available in cell-nav and cell-edit mode, not in row-mode.
1341
+ */
1421
1342
  setColumn(colIdx: number) {
1422
1343
  util.assert(this.navMode !== NavigationMode.row);
1423
1344
  util.assert(0 <= colIdx && colIdx < this.columns.length);
1424
1345
  this.activeColIdx = colIdx;
1425
- // node.setActive(true, { column: tree.activeColIdx + 1 });
1426
- this.setModified(ChangeType.row, this.activeNode);
1346
+
1427
1347
  // Update `wb-active` class for all headers
1428
1348
  if (this.headerElement) {
1429
1349
  for (let rowDiv of this.headerElement.children) {
1430
- // for (let rowDiv of document.querySelector("div.wb-header").children) {
1431
1350
  let i = 0;
1432
1351
  for (let colDiv of rowDiv.children) {
1433
1352
  (colDiv as HTMLElement).classList.toggle("wb-active", i++ === colIdx);
1434
1353
  }
1435
1354
  }
1436
1355
  }
1437
- // Update `wb-active` class for all cell divs
1356
+
1357
+ this.activeNode?.setModified(ChangeType.status);
1358
+
1359
+ // Update `wb-active` class for all cell spans
1438
1360
  for (let rowDiv of this.nodeListElement.children) {
1439
1361
  let i = 0;
1440
1362
  for (let colDiv of rowDiv.children) {
@@ -1443,7 +1365,7 @@ export class Wunderbaum {
1443
1365
  }
1444
1366
  }
1445
1367
 
1446
- /** */
1368
+ /** Set or remove keybaord focus to the tree container. */
1447
1369
  setFocus(flag = true) {
1448
1370
  if (flag) {
1449
1371
  this.element.focus();
@@ -1452,35 +1374,78 @@ export class Wunderbaum {
1452
1374
  }
1453
1375
  }
1454
1376
 
1455
- /** */
1377
+ /** Schedule an update request to reflect a tree change. */
1456
1378
  setModified(change: ChangeType, options?: any): void;
1457
1379
 
1458
- /** */
1380
+ /** Schedule an update request to reflect a single node modification. */
1381
+ setModified(change: ChangeType, node: WunderbaumNode, options?: any): void;
1382
+
1459
1383
  setModified(
1460
1384
  change: ChangeType,
1461
1385
  node?: WunderbaumNode | any,
1462
1386
  options?: any
1463
1387
  ): void {
1388
+ if (this._disableUpdateCount) {
1389
+ // Assuming that we redraw all when enableUpdate() is re-enabled.
1390
+ // this.log(
1391
+ // `IGNORED setModified(${change}) node=${node} (disable level ${this._disableUpdateCount})`
1392
+ // );
1393
+ return;
1394
+ }
1395
+ // this.log(`setModified(${change}) node=${node}`);
1464
1396
  if (!(node instanceof WunderbaumNode)) {
1465
1397
  options = node;
1466
1398
  }
1467
- if (!this.changedSince) {
1468
- this.changedSince = Date.now();
1399
+ const immediate = !!util.getOption(options, "immediate");
1400
+
1401
+ switch (change) {
1402
+ case ChangeType.any:
1403
+ case ChangeType.structure:
1404
+ case ChangeType.header:
1405
+ this.changeRedrawRequestPending = true;
1406
+ this.updateViewport(immediate);
1407
+ break;
1408
+ case ChangeType.vscroll:
1409
+ this.updateViewport(immediate);
1410
+ break;
1411
+ case ChangeType.row:
1412
+ case ChangeType.data:
1413
+ case ChangeType.status:
1414
+ // Single nodes are immedialtely updated if already inside the viewport
1415
+ // (otherwise we can ignore)
1416
+ if (node._rowElem) {
1417
+ node.render({ change: change });
1418
+ }
1419
+ break;
1420
+ default:
1421
+ util.error(`Invalid change type ${change}`);
1469
1422
  }
1470
- this.changes.add(change);
1471
- if (change === ChangeType.structure) {
1472
- this.changedNodes.clear();
1473
- } else if (node && !this.changes.has(ChangeType.structure)) {
1474
- if (this.changedNodes.size < MAX_CHANGED_NODES) {
1475
- this.changedNodes.add(node);
1476
- } else {
1477
- this.changes.add(ChangeType.structure);
1478
- this.changedNodes.clear();
1479
- }
1423
+ }
1424
+
1425
+ /** Set the tree's navigation mode. */
1426
+ setNavigationMode(mode: NavigationMode) {
1427
+ // util.assert(this.cellNavMode);
1428
+ // util.assert(0 <= colIdx && colIdx < this.columns.length);
1429
+ if (mode === this.navMode) {
1430
+ return;
1431
+ }
1432
+ const prevMode = this.navMode;
1433
+ const cellMode = mode !== NavigationMode.row;
1434
+
1435
+ this.navMode = mode;
1436
+ if (cellMode && prevMode === NavigationMode.row) {
1437
+ this.setColumn(0);
1480
1438
  }
1481
- // this.log("setModified(" + change + ")", node);
1439
+ this.element.classList.toggle("wb-cell-mode", cellMode);
1440
+ this.element.classList.toggle(
1441
+ "wb-cell-edit-mode",
1442
+ mode === NavigationMode.cellEdit
1443
+ );
1444
+ // this.setModified(ChangeType.row, this.activeNode);
1445
+ this.activeNode?.setModified(ChangeType.status);
1482
1446
  }
1483
1447
 
1448
+ /** Display tree status (ok, loading, error, noData) using styles and a dummy root node. */
1484
1449
  setStatus(
1485
1450
  status: NodeStatusType,
1486
1451
  message?: string,
@@ -1490,111 +1455,289 @@ export class Wunderbaum {
1490
1455
  }
1491
1456
 
1492
1457
  /** Update column headers and width. */
1493
- updateColumns(opts: any) {
1494
- let modified = false;
1495
- let minWidth = 4;
1496
- let vpWidth = this.element.clientWidth;
1458
+ updateColumns(opts?: any) {
1459
+ opts = Object.assign({ calculateCols: true, updateRows: true }, opts);
1460
+ const minWidth = 4;
1461
+ const vpWidth = this.element.clientWidth;
1497
1462
  let totalWeight = 0;
1498
1463
  let fixedWidth = 0;
1499
1464
 
1500
- // Gather width requests
1501
- this._columnsById = {};
1502
- for (let col of this.columns) {
1503
- this._columnsById[<string>col.id] = col;
1504
- let cw = col.width;
1505
-
1506
- if (!cw || cw === "*") {
1507
- col._weight = 1.0;
1508
- totalWeight += 1.0;
1509
- } else if (typeof cw === "number") {
1510
- col._weight = cw;
1511
- totalWeight += cw;
1512
- } else if (typeof cw === "string" && cw.endsWith("px")) {
1513
- col._weight = 0;
1514
- let px = parseFloat(cw.slice(0, -2));
1515
- if (col._widthPx != px) {
1516
- modified = true;
1517
- col._widthPx = px;
1465
+ let modified = false;
1466
+
1467
+ if (opts.calculateCols) {
1468
+ // Gather width requests
1469
+ this._columnsById = {};
1470
+ for (let col of this.columns) {
1471
+ this._columnsById[<string>col.id] = col;
1472
+ let cw = col.width;
1473
+
1474
+ if (!cw || cw === "*") {
1475
+ col._weight = 1.0;
1476
+ totalWeight += 1.0;
1477
+ } else if (typeof cw === "number") {
1478
+ col._weight = cw;
1479
+ totalWeight += cw;
1480
+ } else if (typeof cw === "string" && cw.endsWith("px")) {
1481
+ col._weight = 0;
1482
+ let px = parseFloat(cw.slice(0, -2));
1483
+ if (col._widthPx != px) {
1484
+ modified = true;
1485
+ col._widthPx = px;
1486
+ }
1487
+ fixedWidth += px;
1488
+ } else {
1489
+ util.error("Invalid column width: " + cw);
1518
1490
  }
1519
- fixedWidth += px;
1520
- } else {
1521
- util.error("Invalid column width: " + cw);
1522
1491
  }
1523
- }
1524
- // Share remaining space between non-fixed columns
1525
- let restPx = Math.max(0, vpWidth - fixedWidth);
1526
- let ofsPx = 0;
1527
-
1528
- for (let col of this.columns) {
1529
- if (col._weight) {
1530
- let px = Math.max(minWidth, (restPx * col._weight) / totalWeight);
1531
- if (col._widthPx != px) {
1532
- modified = true;
1533
- col._widthPx = px;
1492
+ // Share remaining space between non-fixed columns
1493
+ const restPx = Math.max(0, vpWidth - fixedWidth);
1494
+ let ofsPx = 0;
1495
+
1496
+ for (let col of this.columns) {
1497
+ if (col._weight) {
1498
+ const px = Math.max(minWidth, (restPx * col._weight) / totalWeight);
1499
+ if (col._widthPx != px) {
1500
+ modified = true;
1501
+ col._widthPx = px;
1502
+ }
1534
1503
  }
1504
+ col._ofsPx = ofsPx;
1505
+ ofsPx += col._widthPx;
1535
1506
  }
1536
- col._ofsPx = ofsPx;
1537
- ofsPx += col._widthPx;
1538
1507
  }
1539
1508
  // Every column has now a calculated `_ofsPx` and `_widthPx`
1540
1509
  // this.logInfo("UC", this.columns, vpWidth, this.element.clientWidth, this.element);
1541
1510
  // console.trace();
1542
1511
  // util.error("BREAK");
1543
1512
  if (modified) {
1544
- this.renderHeader();
1545
- if (opts.render !== false) {
1546
- this.render();
1513
+ this._renderHeaderMarkup();
1514
+ if (opts.updateRows) {
1515
+ this._updateRows();
1547
1516
  }
1548
1517
  }
1549
1518
  }
1550
1519
 
1551
- /** Render all rows that are visible in the viewport. */
1520
+ /** Create/update header markup from `this.columns` definition.
1521
+ * @internal
1522
+ */
1523
+ protected _renderHeaderMarkup() {
1524
+ if (!this.headerElement) {
1525
+ return;
1526
+ }
1527
+ const headerRow = this.headerElement.querySelector(".wb-row")!;
1528
+ util.assert(headerRow);
1529
+ headerRow.innerHTML = "<span class='wb-col'></span>".repeat(
1530
+ this.columns.length
1531
+ );
1532
+
1533
+ for (let i = 0; i < this.columns.length; i++) {
1534
+ const col = this.columns[i];
1535
+ const colElem = <HTMLElement>headerRow.children[i];
1536
+
1537
+ colElem.style.left = col._ofsPx + "px";
1538
+ colElem.style.width = col._widthPx + "px";
1539
+ // colElem.textContent = col.title || col.id;
1540
+ const title = util.escapeHtml(col.title || col.id);
1541
+ colElem.innerHTML = `<span class="wb-col-title">${title}</span> <span class="wb-col-resizer"></span>`;
1542
+ // colElem.innerHTML = `${title} <span class="wb-col-resizer"></span>`;
1543
+ }
1544
+ }
1545
+
1546
+ /** Render header and all rows that are visible in the viewport (async, throttled). */
1552
1547
  updateViewport(immediate = false) {
1553
1548
  // Call the `throttle` wrapper for `this._updateViewport()` which will
1554
1549
  // execute immediately on the leading edge of a sequence:
1555
- this._updateViewportThrottled();
1556
1550
  if (immediate) {
1557
- this._updateViewportThrottled.flush();
1551
+ this._updateViewport();
1552
+ } else {
1553
+ this._updateViewportThrottled();
1558
1554
  }
1559
1555
  }
1560
1556
 
1557
+ /**
1558
+ * This is the actual update method, which is wrapped inside a throttle method.
1559
+ * This protected method should not be called directly but via
1560
+ * `tree.updateViewport()` or `tree.setModified()`.
1561
+ * It calls `updateColumns()` and `_updateRows()`.
1562
+ * @internal
1563
+ */
1561
1564
  protected _updateViewport() {
1562
- if (this._disableUpdate) {
1565
+ if (this._disableUpdateCount) {
1566
+ this.log(
1567
+ `IGNORED _updateViewport() disable level: ${this._disableUpdateCount}`
1568
+ );
1563
1569
  return;
1564
1570
  }
1571
+ const newNodesOnly = !this.changeRedrawRequestPending;
1572
+ this.changeRedrawRequestPending = false;
1573
+
1565
1574
  let height = this.scrollContainer.clientHeight;
1566
- // We cannot get the height for abolut positioned parent, so look at first col
1575
+ // We cannot get the height for absolute positioned parent, so look at first col
1567
1576
  // let headerHeight = this.headerElement.clientHeight
1568
1577
  // let headerHeight = this.headerElement.children[0].children[0].clientHeight;
1569
1578
  const headerHeight = this.options.headerHeightPx;
1570
- let wantHeight = this.element.clientHeight - headerHeight;
1571
- let ofs = this.scrollContainer.scrollTop;
1579
+ const wantHeight = this.element.clientHeight - headerHeight;
1572
1580
 
1573
1581
  if (Math.abs(height - wantHeight) > 1.0) {
1574
1582
  // this.log("resize", height, wantHeight);
1575
1583
  this.scrollContainer.style.height = wantHeight + "px";
1576
1584
  height = wantHeight;
1577
1585
  }
1586
+ // console.profile(`_updateViewport()`)
1587
+
1588
+ this.updateColumns({ updateRows: false });
1589
+
1590
+ this._updateRows({ newNodesOnly: newNodesOnly });
1591
+
1592
+ // console.profileEnd(`_updateViewport()`)
1578
1593
 
1579
- this.updateColumns({ render: false });
1580
- this.render({
1581
- startIdx: Math.max(0, ofs / ROW_HEIGHT - RENDER_MAX_PREFETCH),
1582
- endIdx: Math.max(0, (ofs + height) / ROW_HEIGHT + RENDER_MAX_PREFETCH),
1583
- });
1584
1594
  this._callEvent("update");
1585
1595
  }
1586
1596
 
1587
- /** Call callback(node) for all nodes in hierarchical order (depth-first).
1597
+ /**
1598
+ * Assert that TR order matches the natural node order
1599
+ * @internal
1600
+ */
1601
+ protected _validateRows(): boolean {
1602
+ let trs = this.nodeListElement.childNodes;
1603
+ let i = 0;
1604
+ let prev = -1;
1605
+ let ok = true;
1606
+ trs.forEach((element) => {
1607
+ const tr = element as HTMLTableRowElement;
1608
+ const top = Number.parseInt(tr.style.top);
1609
+ const n = (<any>tr)._wb_node;
1610
+ // if (i < 4) {
1611
+ // console.info(
1612
+ // `TR#${i}, rowIdx=${n._rowIdx} , top=${top}px: '${n.title}'`
1613
+ // );
1614
+ // }
1615
+ if (prev >= 0 && top !== prev + ROW_HEIGHT) {
1616
+ n.logWarn(
1617
+ `TR order mismatch at index ${i}: top=${top}px != ${
1618
+ prev + ROW_HEIGHT
1619
+ }`
1620
+ );
1621
+ // throw new Error("fault");
1622
+ ok = false;
1623
+ }
1624
+ prev = top;
1625
+ i++;
1626
+ });
1627
+ return ok;
1628
+ }
1629
+
1630
+ /*
1631
+ * - Traverse all *visible* of the whole tree, i.e. skip collapsed nodes.
1632
+ * - Store count of rows to `tree.treeRowCount`.
1633
+ * - Renumber `node._rowIdx` for all visible nodes.
1634
+ * - Calculate the index range that must be rendered to fill the viewport
1635
+ * (including upper and lower prefetch)
1636
+ * -
1637
+ */
1638
+ protected _updateRows(opts?: any): boolean {
1639
+ const label = this.logTime("_updateRows");
1640
+ // this.log("_updateRows", opts)
1641
+ opts = Object.assign({ newNodesOnly: false }, opts);
1642
+ const newNodesOnly = !!opts.newNodesOnly;
1643
+
1644
+ const row_height = ROW_HEIGHT;
1645
+ const vp_height = this.scrollContainer.clientHeight;
1646
+ const prefetch = RENDER_MAX_PREFETCH;
1647
+ const ofs = this.scrollContainer.scrollTop;
1648
+
1649
+ let startIdx = Math.max(0, ofs / row_height - prefetch);
1650
+ startIdx = Math.floor(startIdx);
1651
+ // Make sure start is always even, so the alternating row colors don't
1652
+ // change when scrolling:
1653
+ if (startIdx % 2) {
1654
+ startIdx--;
1655
+ }
1656
+ let endIdx = Math.max(0, (ofs + vp_height) / row_height + prefetch);
1657
+ endIdx = Math.ceil(endIdx);
1658
+
1659
+ // const obsoleteViewNodes = this.viewNodes;
1660
+ // this.viewNodes = new Set();
1661
+ // const viewNodes = this.viewNodes;
1662
+ // this.debug("render", opts);
1663
+ const obsoleteNodes = new Set<WunderbaumNode>();
1664
+ this.nodeListElement.childNodes.forEach((elem) => {
1665
+ const tr = elem as HTMLTableRowElement;
1666
+ obsoleteNodes.add((<any>tr)._wb_node);
1667
+ });
1668
+
1669
+ let idx = 0;
1670
+ let top = 0;
1671
+ let modified = false;
1672
+ let prevElem: HTMLDivElement | "first" | "last" = "first";
1673
+
1674
+ this.visitRows(function (node) {
1675
+ // node.log("visit")
1676
+ const rowDiv = node._rowElem;
1677
+
1678
+ // Renumber all expanded nodes
1679
+ if (node._rowIdx !== idx) {
1680
+ node._rowIdx = idx;
1681
+ modified = true;
1682
+ }
1683
+
1684
+ if (idx < startIdx || idx > endIdx) {
1685
+ // row is outside viewport bounds
1686
+ if (rowDiv) {
1687
+ prevElem = rowDiv;
1688
+ }
1689
+ } else if (rowDiv && newNodesOnly) {
1690
+ obsoleteNodes.delete(node);
1691
+ // no need to update existing node markup
1692
+ rowDiv.style.top = idx * ROW_HEIGHT + "px";
1693
+ prevElem = rowDiv;
1694
+ } else {
1695
+ obsoleteNodes.delete(node);
1696
+ // Create new markup
1697
+ if (rowDiv) {
1698
+ rowDiv.style.top = idx * ROW_HEIGHT + "px";
1699
+ }
1700
+ node.render({ top: top, after: prevElem });
1701
+ // node.log("render", top, prevElem, "=>", node._rowElem);
1702
+ prevElem = node._rowElem!;
1703
+ }
1704
+ idx++;
1705
+ top += row_height;
1706
+ });
1707
+ this.treeRowCount = idx;
1708
+ for (const n of obsoleteNodes) {
1709
+ n._callEvent("discard");
1710
+ n.removeMarkup();
1711
+ }
1712
+ // Resize tree container
1713
+ this.nodeListElement.style.height = `${top}px`;
1714
+ // this.log(
1715
+ // `render(scrollOfs:${ofs}, ${startIdx}..${endIdx})`,
1716
+ // this.nodeListElement.style.height
1717
+ // );
1718
+ this.logTimeEnd(label);
1719
+ this._validateRows();
1720
+ return modified;
1721
+ }
1722
+
1723
+ /**
1724
+ * Call callback(node) for all nodes in hierarchical order (depth-first).
1588
1725
  *
1589
1726
  * @param {function} callback the callback function.
1590
- * Return false to stop iteration, return "skip" to skip this node and children only.
1727
+ * Return false to stop iteration, return "skip" to skip this node and
1728
+ * children only.
1591
1729
  * @returns {boolean} false, if the iterator was stopped.
1592
1730
  */
1593
1731
  visit(callback: (node: WunderbaumNode) => any) {
1594
1732
  return this.root.visit(callback, false);
1595
1733
  }
1596
1734
 
1597
- /** Call fn(node) for all nodes in vertical order, top down (or bottom up).<br>
1735
+ /**
1736
+ * Call fn(node) for all nodes in vertical order, top down (or bottom up).
1737
+ *
1738
+ * Note that this considers expansion state, i.e. children of collapsed nodes
1739
+ * are skipped.
1740
+ *
1598
1741
  * Stop iteration, if fn() returns false.<br>
1599
1742
  * Return false if iteration was stopped.
1600
1743
  *
@@ -1695,7 +1838,8 @@ export class Wunderbaum {
1695
1838
  return true;
1696
1839
  }
1697
1840
 
1698
- /** Call fn(node) for all nodes in vertical order, bottom up.
1841
+ /**
1842
+ * Call fn(node) for all nodes in vertical order, bottom up.
1699
1843
  * @internal
1700
1844
  */
1701
1845
  protected _visitRowsUp(
@@ -1750,20 +1894,37 @@ export class Wunderbaum {
1750
1894
  return true;
1751
1895
  }
1752
1896
 
1753
- /** . */
1897
+ /**
1898
+ * Reload the tree with a new source.
1899
+ *
1900
+ * Previous data is cleared.
1901
+ * Pass `options.columns` to define a header (may also be part of `source.columns`).
1902
+ */
1754
1903
  load(source: any, options: any = {}) {
1755
1904
  this.clear();
1756
1905
  const columns = options.columns || source.columns;
1757
1906
  if (columns) {
1758
1907
  this.columns = options.columns;
1759
- this.renderHeader();
1760
- // this.updateColumns({ render: false });
1908
+ // this._renderHeaderMarkup();
1909
+ this.updateColumns({ calculateCols: false });
1761
1910
  }
1762
1911
  return this.root.load(source);
1763
1912
  }
1764
1913
 
1765
1914
  /**
1915
+ * Disable render requests during operations that would trigger many updates.
1766
1916
  *
1917
+ * ```js
1918
+ * try {
1919
+ * tree.enableUpdate(false);
1920
+ * // ... (long running operation that would trigger many updates)
1921
+ * foo();
1922
+ * // ... NOTE: make sure that async operations have finished, e.g.
1923
+ * await foo();
1924
+ * } finally {
1925
+ * tree.enableUpdate(true);
1926
+ * }
1927
+ * ```
1767
1928
  */
1768
1929
  public enableUpdate(flag: boolean): void {
1769
1930
  /*
@@ -1771,19 +1932,24 @@ export class Wunderbaum {
1771
1932
  1 >-------------------------------------<
1772
1933
  2 >--------------------<
1773
1934
  3 >--------------------------<
1774
-
1775
- 5
1776
-
1777
1935
  */
1778
- // this.logDebug( `enableUpdate(${flag}): count=${this._disableUpdateCount}...` );
1779
1936
  if (flag) {
1780
- util.assert(this._disableUpdateCount > 0);
1937
+ util.assert(
1938
+ this._disableUpdateCount > 0,
1939
+ "enableUpdate(true) was called too often"
1940
+ );
1781
1941
  this._disableUpdateCount--;
1942
+ // this.logDebug(
1943
+ // `enableUpdate(${flag}): count -> ${this._disableUpdateCount}...`
1944
+ // );
1782
1945
  if (this._disableUpdateCount === 0) {
1783
1946
  this.updateViewport();
1784
1947
  }
1785
1948
  } else {
1786
1949
  this._disableUpdateCount++;
1950
+ // this.logDebug(
1951
+ // `enableUpdate(${flag}): count -> ${this._disableUpdateCount}...`
1952
+ // );
1787
1953
  // this._disableUpdate = Date.now();
1788
1954
  }
1789
1955
  // return !flag; // return previous value