virtual-tree-canvas 0.1.0 → 0.3.0

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.
@@ -76,6 +76,9 @@ export class TreeViewController {
76
76
 
77
77
  setData(nodes) {
78
78
  this.inspector = null;
79
+ if (this.columnModel.columns.length === 1 && this.columnModel.columns[0]?.kind === 'inspectorPane') {
80
+ this.columnModel.setColumns([]);
81
+ }
79
82
  this.model.setTree(nodes);
80
83
  this.expansion.expandToDepth(this.initialExpandDepth);
81
84
  this.searchIndex.rebuild(this.model);
@@ -86,17 +89,21 @@ export class TreeViewController {
86
89
  setModel(model, meta = {}, options = {}) {
87
90
  const builder = new ModelInspectorBuilder();
88
91
  const presentation = options.presentation ?? options.mode ?? 'table';
92
+ const previousExpanded = new Set(this.model.expanded);
93
+ const shouldPreserveExpansion = Boolean(this.inspector);
89
94
  const inspectorOptions = {
90
95
  presentation,
91
96
  flatRoot: Boolean(options.flatRoot),
92
97
  enforceMeta: Boolean(options.enforceMeta),
93
98
  filter: Boolean(options.filter),
99
+ markUpdated: options.markUpdated !== false,
94
100
  };
95
101
  const nodes = builder.build(model, meta, inspectorOptions);
96
102
  this.inspector = { model, meta, builder, presentation, options: inspectorOptions };
97
103
  this.setColumns(presentation === 'pane' ? inspectorPaneColumns() : inspectorColumns());
98
104
  this.model.setTree(nodes);
99
- this.expansion.expandToDepth(this.initialExpandDepth);
105
+ if (shouldPreserveExpansion) this.#restoreExpansion(previousExpanded);
106
+ else this.expansion.expandToDepth(this.initialExpandDepth);
100
107
  this.searchIndex.rebuild(this.model);
101
108
  this.#rebuildRows();
102
109
  this.events.emit('modelchange', { model, meta, structural: true });
@@ -113,7 +120,9 @@ export class TreeViewController {
113
120
  data.value = newValue;
114
121
  data.valueType = Array.isArray(newValue) ? 'array' : newValue === null ? 'null' : typeof newValue === 'object' ? 'object' : typeof newValue;
115
122
  data.valueText = formatInspectorValue(newValue, data.meta);
116
- this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
123
+ if (this.inspector.options.markUpdated !== false) {
124
+ this.setDynamicState([{ id: nodeId, state: { updated: true } }]);
125
+ }
117
126
  const detail = { path: data.path, oldValue, newValue, nodeId, editorType };
118
127
  this.events.emit('valuechange', detail);
119
128
  this.events.emit('modelchange', { model: this.inspector.model, path: data.path, oldValue, newValue, nodeId });
@@ -275,6 +284,11 @@ export class TreeViewController {
275
284
  }
276
285
 
277
286
  expand(nodeId) {
287
+ if (this.filterQuery && this.rowModel.expandFilterBranch(nodeId)) {
288
+ this.#rebuildRows();
289
+ this.events.emit('expand', { nodeId, filter: true });
290
+ return true;
291
+ }
278
292
  if (!this.expansion.expand(nodeId)) return false;
279
293
  this.#rebuildRows();
280
294
  this.events.emit('expand', { nodeId });
@@ -282,14 +296,19 @@ export class TreeViewController {
282
296
  }
283
297
 
284
298
  collapse(nodeId) {
285
- if (!this.expansion.collapse(nodeId)) return false;
299
+ const row = this.rowModel.getRowById(nodeId);
300
+ const shouldCollapseFilterBranch = Boolean(this.filterQuery && row?.expanded);
301
+ const changed = this.expansion.collapse(nodeId);
302
+ if (shouldCollapseFilterBranch) this.rowModel.collapseFilterBranch(nodeId);
303
+ if (!changed && !shouldCollapseFilterBranch) return false;
286
304
  this.#rebuildRows();
287
- this.events.emit('collapse', { nodeId });
305
+ this.events.emit('collapse', { nodeId, filter: shouldCollapseFilterBranch });
288
306
  return true;
289
307
  }
290
308
 
291
309
  toggle(nodeId) {
292
- return this.expansion.isExpanded(nodeId) ? this.collapse(nodeId) : this.expand(nodeId);
310
+ const row = this.rowModel.getRowById(nodeId);
311
+ return (row?.expanded ?? this.expansion.isExpanded(nodeId)) ? this.collapse(nodeId) : this.expand(nodeId);
293
312
  }
294
313
 
295
314
  expandAll() {
@@ -573,12 +592,17 @@ export class TreeViewController {
573
592
  searchMatches: this.searchHighlights,
574
593
  sort: this.columnModel.sort,
575
594
  sortValues: this.sortValueSnapshot ? Array.from(this.sortValueSnapshot) : null,
595
+ inspectorPaneLabelEnd: this.#computeInspectorPaneLabelEnd(visibleRange),
576
596
  filterQuery: this.filterQuery,
577
597
  headerFilter: Boolean(this.inspector?.options?.filter),
578
598
  stats: this.getStats(),
579
599
  };
580
600
  }
581
601
 
602
+ closeEditor() {
603
+ this.events.emit('editorclose', {});
604
+ }
605
+
582
606
  hitTest(clientX, clientY) {
583
607
  const localX = clientX - (this.viewport.renderInsetX ?? 0);
584
608
  const localY = clientY - (this.viewport.renderInsetY ?? 0);
@@ -605,11 +629,16 @@ export class TreeViewController {
605
629
  if (column.kind === 'inspectorPane') {
606
630
  const localX = x - column.x;
607
631
  const node = this.model.nodes[row.nodeIndex];
632
+ if (node?.data?.valueType === 'object') {
633
+ const treeX = row.depth * this.rowModel.indentWidth;
634
+ part = localX >= treeX + 4 && localX <= treeX + 22 ? 'chevron' : 'label';
635
+ return { area: 'row', part, row, column, x, y: rowY };
636
+ }
608
637
  if (node?.data?.valueType === 'array') {
609
638
  if (localX >= column.width - 54 && localX <= column.width - 32) part = 'arrayAdd';
610
639
  else if (localX >= column.width - 28 && localX <= column.width - 6) part = 'arrayRemove';
611
640
  else {
612
- const editorLeft = Math.min(Math.max(210, column.width * 0.42), column.width - 180);
641
+ const editorLeft = this.getInspectorPaneLayout(this.#visibleInspectorPaneWidth(column), row, node?.data?.editorType).editorLeft;
613
642
  if (localX >= editorLeft) part = 'editor';
614
643
  else {
615
644
  const treeX = row.depth * this.rowModel.indentWidth;
@@ -618,8 +647,8 @@ export class TreeViewController {
618
647
  }
619
648
  return { area: 'row', part, row, column, x, y: rowY };
620
649
  }
621
- const editorLeft = Math.min(Math.max(210, column.width * 0.42), column.width - 180);
622
- if (localX >= editorLeft) part = this.#inspectorEditorPart(row, localX - editorLeft, column.width - editorLeft);
650
+ const layout = this.getInspectorPaneLayout(this.#visibleInspectorPaneWidth(column), row, node?.data?.editorType);
651
+ if (localX >= layout.editorLeft) part = this.#inspectorEditorPart(row, localX - layout.editorLeft, layout.editorWidth);
623
652
  else {
624
653
  const treeX = row.depth * this.rowModel.indentWidth;
625
654
  if (localX >= treeX + 4 && localX <= treeX + 22) part = 'chevron';
@@ -642,13 +671,23 @@ export class TreeViewController {
642
671
  if (!data?.inspector) return 'cell';
643
672
  if (data.editorType === 'checkbox') return 'checkbox';
644
673
  if (data.editorType === 'range') {
645
- const numberLeft = Math.max(50, editorWidth - 64);
674
+ const width = Math.max(24, editorWidth - 20);
675
+ const valueWidth = Math.min(64, Math.max(42, width * 0.28));
676
+ const numberLeft = editorWidth - valueWidth - 10;
646
677
  return localEditorX >= numberLeft ? 'number' : 'range';
647
678
  }
648
679
  if (data.editorType === 'button') return 'button';
649
680
  return 'editor';
650
681
  }
651
682
 
683
+ #visibleInspectorPaneWidth(column) {
684
+ return Math.max(1, Math.min(column.width, this.viewport.scrollX + this.viewport.viewportWidth - column.x));
685
+ }
686
+
687
+ getInspectorPaneLayout(width, row = null, editorType = '') {
688
+ return inspectorPaneLayout(width, row?.depth ?? 0, this.rowModel.indentWidth, editorType, this.#computeInspectorPaneLabelEnd(this.rowModel.getVisibleRange(this.viewport, 4)));
689
+ }
690
+
652
691
  getCellClientRect(hit) {
653
692
  if (!hit?.row || !hit.column || !this.canvas) return { x: 0, y: 0, width: 0, height: 0 };
654
693
  const rect = this.canvas.getBoundingClientRect();
@@ -671,8 +710,57 @@ export class TreeViewController {
671
710
  };
672
711
  }
673
712
 
713
+ getTooltipForHit(hit) {
714
+ if (!hit?.row || !hit.column) return null;
715
+ const node = this.model.nodes[hit.row.nodeIndex];
716
+ if (!node) return null;
717
+ const state = this.model.dynamicState.get(hit.row.nodeId) ?? {};
718
+ const rect = this.getCellClientRect(hit);
719
+ let text = '';
720
+ let width = rect.width;
721
+
722
+ if (hit.column.kind === 'inspectorPane') {
723
+ const data = node.data ?? {};
724
+ const visibleWidth = this.#visibleInspectorPaneWidth(hit.column);
725
+ const layout = this.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
726
+ if (hit.part === 'label' || hit.part === 'chevron') {
727
+ const labelX = hit.row.depth * this.rowModel.indentWidth + (hit.row.hasChildren ? 28 : 24);
728
+ text = node.label ?? node.id;
729
+ width = data.valueType === 'object' ? Math.max(0, visibleWidth - labelX - 8) : Math.max(0, layout.editorLeft - labelX - 8);
730
+ } else if (hit.part === 'arrayAdd' || hit.part === 'arrayRemove') {
731
+ return null;
732
+ } else {
733
+ text = inspectorTooltipValue(node);
734
+ width = Math.max(0, layout.editorWidth - 20);
735
+ }
736
+ } else if (hit.column.kind === 'tree') {
737
+ const labelX = hit.row.depth * this.rowModel.indentWidth + 50;
738
+ text = node.label ?? node.id;
739
+ width = Math.max(0, hit.column.width - labelX - 6);
740
+ } else if (hit.column.kind === 'inspectorValue') {
741
+ text = inspectorTooltipValue(node);
742
+ width = Math.max(0, hit.column.width - 20);
743
+ } else if (hit.column.kind === 'inspectorType') {
744
+ text = node.data?.valueType ?? '';
745
+ width = Math.max(0, hit.column.width - 20);
746
+ } else if (hit.column.kind === 'inspectorDescription') {
747
+ text = node.data?.meta?.description ?? '';
748
+ width = Math.max(0, hit.column.width - 20);
749
+ } else if (typeof hit.column.value === 'function') {
750
+ const value = hit.column.value(node, state);
751
+ text = value == null ? '' : String(value);
752
+ width = Math.max(0, hit.column.width - 20);
753
+ }
754
+
755
+ text = String(text ?? '');
756
+ if (!text || !isProbablyTruncated(text, width)) return null;
757
+ return { text, rect, nodeId: node.id, part: hit.part, columnId: hit.column.id };
758
+ }
759
+
674
760
  resize(width, height) {
675
761
  this.viewport.resize(width, height);
762
+ this.#fitInspectorPaneColumn(width);
763
+ this.#syncContentSize();
676
764
  this.events.emit('viewportchange', this.getViewportState());
677
765
  }
678
766
 
@@ -715,6 +803,7 @@ export class TreeViewController {
715
803
  }
716
804
 
717
805
  #syncContentSize() {
806
+ if (this.viewport.viewportWidth > 1) this.#fitInspectorPaneColumn(this.viewport.viewportWidth);
718
807
  this.viewport.setContentSize(this.columnModel.contentWidth, this.rowModel.contentHeight);
719
808
  }
720
809
 
@@ -726,6 +815,13 @@ export class TreeViewController {
726
815
  this.setDynamicState(this.patchBatcher.flush());
727
816
  }
728
817
 
818
+ #restoreExpansion(expandedIds) {
819
+ this.model.expanded.clear();
820
+ for (const id of expandedIds) {
821
+ if (this.model.index.getNode(id) && this.expansion.hasChildren(id)) this.model.expanded.add(id);
822
+ }
823
+ }
824
+
729
825
  #focusedRowIndex() {
730
826
  if (this.focusedId) {
731
827
  const row = this.rowModel.getRowById(this.focusedId);
@@ -740,12 +836,42 @@ export class TreeViewController {
740
836
  const height = this.canvas.clientHeight;
741
837
  if (width > 0 && height > 0 && (this.viewport.viewportWidth !== width || this.viewport.viewportHeight !== height)) {
742
838
  this.viewport.resize(width, height);
839
+ this.#fitInspectorPaneColumn(width);
840
+ this.#syncContentSize();
743
841
  }
744
842
  }
745
843
 
844
+ #computeInspectorPaneLabelEnd(visibleRange) {
845
+ const column = this.columnModel.columns.find((item) => item.kind === 'inspectorPane');
846
+ if (!column) return 0;
847
+ let labelEnd = 0;
848
+ for (let i = visibleRange.first; i <= visibleRange.last; i++) {
849
+ const row = this.rowModel.rows[i];
850
+ if (!row) continue;
851
+ const node = this.model.nodes[row.nodeIndex];
852
+ if (!node) continue;
853
+ if (node.data?.valueType === 'object') continue;
854
+ const indentX = row.depth * this.rowModel.indentWidth;
855
+ const labelX = indentX + (row.hasChildren ? 28 : 24);
856
+ const labelWidth = String(node.label ?? node.id ?? '').length * 6.4;
857
+ labelEnd = Math.max(labelEnd, labelX + labelWidth + 12);
858
+ }
859
+ return Math.min(labelEnd, Math.max(90, this.#visibleInspectorPaneWidth(column) - 72));
860
+ }
861
+
862
+ #fitInspectorPaneColumn(width) {
863
+ const column = this.columnModel.columns.length === 1 ? this.columnModel.columns[0] : null;
864
+ if (column?.kind !== 'inspectorPane' && column?.kind !== 'tree') return;
865
+ if (column.kind === 'inspectorPane' && this.inspector?.options?.presentation !== 'pane') return;
866
+ const nextWidth = Math.max(column.minWidth, Math.floor(width));
867
+ if (Math.abs(column.width - nextWidth) < 1) return;
868
+ this.columnModel.resizeColumn(column.id, nextWidth);
869
+ }
870
+
746
871
  #workerRowOptions(overrides = {}) {
747
872
  return {
748
873
  expandedIds: Array.from(this.expansion.model.expanded),
874
+ filterCollapsedIds: Array.from(this.rowModel.filterCollapsed ?? []),
749
875
  rowHeight: this.rowModel.rowHeight,
750
876
  indentWidth: this.rowModel.indentWidth,
751
877
  sort: this.columnModel.sort,
@@ -777,6 +903,22 @@ export class TreeViewController {
777
903
  }
778
904
  }
779
905
 
906
+ function inspectorPaneLayout(width, depth = 0, indentWidth = 18, editorType = '', labelEnd = 0) {
907
+ const safeWidth = Math.max(1, width);
908
+ const rightPadding = 14;
909
+ if (editorType === 'checkbox') {
910
+ const editorWidth = 34;
911
+ return { editorLeft: Math.max(64, safeWidth - rightPadding - editorWidth), editorWidth };
912
+ }
913
+ const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
914
+ const minLabelEnd = Math.max(88, depth * indentWidth + 104);
915
+ const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
916
+ const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
917
+ const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
918
+ const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
919
+ return { editorLeft, editorWidth };
920
+ }
921
+
780
922
  function createDefaultArrayItem(meta = {}) {
781
923
  if (meta.itemFactory) return meta.itemFactory();
782
924
  if (meta.itemType === 'number') return 0;
@@ -820,6 +962,19 @@ function matchesFilter(node, state, path, query) {
820
962
  return values.some((value) => String(value ?? '').toLowerCase().includes(query));
821
963
  }
822
964
 
965
+ function inspectorTooltipValue(node) {
966
+ const data = node.data ?? {};
967
+ if (data.editorType === 'checkbox') return '';
968
+ if (data.editorType === 'button') return data.meta?.button ?? node.label ?? '';
969
+ if (data.valueType === 'object') return '';
970
+ return data.valueText ?? data.value ?? '';
971
+ }
972
+
973
+ function isProbablyTruncated(text, width) {
974
+ if (width <= 0) return Boolean(text);
975
+ return String(text).length * 6.4 > width;
976
+ }
977
+
823
978
  function now() {
824
979
  return globalThis.performance?.now?.() ?? Date.now();
825
980
  }