virtual-tree-canvas 0.4.0 → 0.6.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +115 -28
  3. package/docs/icon-catalog.html +67 -0
  4. package/docs/icon-catalog.json +380 -0
  5. package/docs/icon-catalog.md +167 -0
  6. package/examples/generate-tree.js +86 -0
  7. package/examples/reorder-table.html +33 -0
  8. package/examples/units.html +43 -0
  9. package/package.json +7 -3
  10. package/resources/icons/bicycle.svg +1 -0
  11. package/resources/icons/bird.svg +1 -0
  12. package/resources/icons/box.svg +1 -0
  13. package/resources/icons/building.svg +1 -0
  14. package/resources/icons/calendar.svg +1 -0
  15. package/resources/icons/car.svg +1 -0
  16. package/resources/icons/cat.svg +1 -0
  17. package/resources/icons/child.svg +1 -0
  18. package/resources/icons/clock.svg +1 -0
  19. package/resources/icons/cloud.svg +1 -0
  20. package/resources/icons/data-bus.svg +1 -0
  21. package/resources/icons/database.svg +1 -0
  22. package/resources/icons/dog.svg +1 -0
  23. package/resources/icons/drone.svg +1 -0
  24. package/resources/icons/fish.svg +1 -0
  25. package/resources/icons/flower.svg +1 -0
  26. package/resources/icons/fog.svg +1 -0
  27. package/resources/icons/globe.svg +1 -0
  28. package/resources/icons/grip.svg +1 -0
  29. package/resources/icons/heart.svg +1 -0
  30. package/resources/icons/helicopter.svg +1 -0
  31. package/resources/icons/insect.svg +1 -0
  32. package/resources/icons/leaf.svg +1 -0
  33. package/resources/icons/link.svg +1 -0
  34. package/resources/icons/lock.svg +1 -0
  35. package/resources/icons/moon.svg +1 -0
  36. package/resources/icons/network.svg +1 -0
  37. package/resources/icons/people.svg +1 -0
  38. package/resources/icons/person.svg +1 -0
  39. package/resources/icons/pin.svg +1 -0
  40. package/resources/icons/radar.svg +1 -0
  41. package/resources/icons/rain.svg +1 -0
  42. package/resources/icons/seedling.svg +1 -0
  43. package/resources/icons/sensor.svg +1 -0
  44. package/resources/icons/server.svg +1 -0
  45. package/resources/icons/ship.svg +1 -0
  46. package/resources/icons/snow.svg +1 -0
  47. package/resources/icons/star.svg +1 -0
  48. package/resources/icons/storm.svg +1 -0
  49. package/resources/icons/sun.svg +1 -0
  50. package/resources/icons/thermometer.svg +1 -0
  51. package/resources/icons/tree.svg +1 -0
  52. package/resources/icons/wind.svg +1 -0
  53. package/src/assets/icons.js +73 -25
  54. package/src/core/icon-registry.js +6 -8
  55. package/src/core/theme-manager.js +15 -4
  56. package/src/core/tree-column-model.js +16 -3
  57. package/src/core/tree-model.js +20 -0
  58. package/src/core/view-options.js +30 -0
  59. package/src/index.d.ts +166 -5
  60. package/src/index.js +3 -0
  61. package/src/input/index.js +1 -0
  62. package/src/input/row-reorder-input.js +143 -0
  63. package/src/input/tree-filter-bar.js +55 -0
  64. package/src/input/tree-tooltip.js +55 -0
  65. package/src/input/tree-view-input-controller.js +5 -0
  66. package/src/inspector/cell-editor-manager.js +42 -22
  67. package/src/inspector/editor-resolver.js +6 -1
  68. package/src/inspector/numeric-layout.js +16 -0
  69. package/src/renderers/tree-row-renderer.js +79 -13
  70. package/src/tree-view-controller.js +261 -34
  71. package/src/tree-view.js +181 -0
  72. package/src/view/styles.js +14 -0
  73. package/src/assets/icons/air.svg +0 -1
  74. package/src/assets/icons/radar.svg +0 -1
  75. /package/{src/assets → resources}/icons/aircraft.svg +0 -0
  76. /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
  77. /package/{src/assets → resources}/icons/control.svg +0 -0
  78. /package/{src/assets → resources}/icons/damage.svg +0 -0
  79. /package/{src/assets → resources}/icons/error.svg +0 -0
  80. /package/{src/assets → resources}/icons/folder.svg +0 -0
  81. /package/{src/assets → resources}/icons/ground.svg +0 -0
  82. /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
  83. /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
  84. /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
  85. /package/{src/assets → resources}/icons/munition.svg +0 -0
  86. /package/{src/assets → resources}/icons/placeholder.svg +0 -0
  87. /package/{src/assets → resources}/icons/point.svg +0 -0
  88. /package/{src/assets → resources}/icons/situation.svg +0 -0
  89. /package/{src/assets → resources}/icons/space.svg +0 -0
  90. /package/{src/assets → resources}/icons/subsurface.svg +0 -0
  91. /package/{src/assets → resources}/icons/surface.svg +0 -0
  92. /package/{src/assets → resources}/icons/task.svg +0 -0
  93. /package/{src/assets → resources}/icons/track.svg +0 -0
  94. /package/{src/assets → resources}/icons/warning.svg +0 -0
package/src/index.d.ts CHANGED
@@ -1,10 +1,39 @@
1
+ export const builtinIconNames: readonly string[];
2
+ export interface RowReorderDetail {
3
+ nodeId: string;
4
+ parentId: string | null;
5
+ fromIndex: number;
6
+ toIndex: number;
7
+ siblingOrder: string[];
8
+ order: string[];
9
+ source: 'api' | 'pointer' | 'button' | 'keyboard';
10
+ }
11
+
12
+ export interface TreeViewOptions {
13
+ nativeScrollbars?: boolean;
14
+ iconsBaseUrl?: string | URL;
15
+ iconRegistry?: IconRegistry;
16
+ rowReorder?: boolean;
17
+ autoRender?: boolean;
18
+ tooltip?: boolean;
19
+ canvas?: HTMLCanvasElement;
20
+ host?: HTMLElement;
21
+ [key: string]: any;
22
+ }
23
+
24
+ export class TreeTooltip {
25
+ constructor(options: { controller: TreeViewController; host?: HTMLElement });
26
+ hide(): void;
27
+ destroy(): void;
28
+ }
29
+
1
30
  export type TreeViewAlign = 'start' | 'center' | 'end' | 'nearest';
2
31
 
3
32
  export type IconDrawFunction = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) => void;
4
33
  export type IconSource = string | CanvasImageSource | IconDrawFunction;
5
34
 
6
35
  export class IconRegistry {
7
- constructor(options?: { pixelRatio?: number });
36
+ constructor(options?: { pixelRatio?: number; iconsBaseUrl?: string | URL });
8
37
  register(name: string, icon: IconSource): any;
9
38
  get(name: string): any;
10
39
  onChange(listener: () => void): () => void;
@@ -33,6 +62,10 @@ export type MetaRule = {
33
62
  max?: number;
34
63
  step?: number;
35
64
  integer?: boolean;
65
+ /** Display-only suffix for numeric values. Never appended to the model value. */
66
+ unit?: string;
67
+ /** Display decimal places (0–20). Editing retains the original precision. */
68
+ precision?: number;
36
69
  readonly?: boolean;
37
70
  disabled?: boolean;
38
71
  updated?: boolean;
@@ -85,16 +118,46 @@ export class TreeViewController {
85
118
  rowModel: any;
86
119
  expansion: any;
87
120
  selection: any;
88
- constructor(options?: Record<string, any> & { nativeScrollbars?: boolean });
121
+ constructor(options?: TreeViewOptions);
122
+ iconRegistry: IconRegistry;
123
+ tooltip?: TreeTooltip | null;
124
+ rowReorder: boolean;
125
+ editable: boolean;
126
+ initialExpandDepth: number;
127
+ filterOptions: {caseSensitive: boolean; wholeWord: boolean};
128
+ model: any;
129
+ inspector: any;
130
+ columnModel: any;
131
+ setEditable(enabled: boolean): void;
132
+ setInitialExpandDepth(depth: number): void;
133
+ setIconResolver(resolver: TreeViewConfiguration['iconResolver']): void;
134
+ setInspectorOptions(options: {filter?: boolean; markUpdated?: boolean}): void;
135
+ setHeaderFilter(enabled: boolean): void;
136
+ setInspectorValue(path: string, value: any, options?: InspectorWriteOptions): boolean;
137
+ updateInspectorValue(nodeId: string, value: any, editorType?: string, options?: InspectorWriteOptions): boolean;
138
+ closeEditor(): void;
139
+ clearSearch(): void;
140
+ getSearchState(): TreeSearchState;
141
+ nextSearchResult(): string | null;
142
+ previousSearchResult(): string | null;
143
+ setRowReorder(enabled: boolean): void;
144
+ canReorderRows(): boolean;
145
+ getRowOrder(parentId?: string | null): string[];
146
+ moveRow(nodeId: string, targetIndex: number, options?: {source?: RowReorderDetail["source"]}): boolean;
147
+ moveRowBy(nodeId: string, offset: number, options?: {source?: RowReorderDetail["source"]}): boolean;
148
+ requestRender(force?: boolean): void;
149
+ cancelRender(): void;
150
+ attachTooltip(options?: {host?: HTMLElement}): TreeTooltip;
89
151
  initialize(canvas: HTMLCanvasElement): this;
90
152
  attachCellEditor(options?: { host?: HTMLElement | null }): CellEditorManager;
91
153
  attachInput(options?: { cellEditor?: CellEditorManager | null }): TreeViewInputController;
92
154
  destroy(): void;
93
- on(type: string, listener: (event: any) => void): any;
155
+ on<K extends keyof TreeViewEvents>(type: K, listener: (event: TreeViewEvent<K>) => void): () => void;
156
+ on(type: string, listener: (event: any) => void): () => void;
94
157
  off(type: string, listener: (event: any) => void): void;
95
- setData(nodes: TreeNode[]): void;
158
+ setData(nodes: TreeNode[], options?: { iconResolver?: TreeViewConfiguration['iconResolver'] }): void;
96
159
  setModel(model: any, meta?: Record<string, MetaRule>, options?: Record<string, any>): void;
97
- setColumns(columns: Column[]): void;
160
+ setColumns(columns: Column[] | null): void;
98
161
  setDynamicState(patches: DynamicPatch[]): void;
99
162
  setTheme(theme: any): void;
100
163
  setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
@@ -125,5 +188,103 @@ export class TreeViewInputController {
125
188
  export class CellEditorManager {
126
189
  constructor(options: { controller: TreeViewController; host?: HTMLElement | null });
127
190
  destroy(): void;
191
+ setEditable(enabled: boolean): void;
128
192
  close(): void;
129
193
  }
194
+
195
+ export interface InspectorWriteOptions { emit?: boolean; source?: string }
196
+ export interface TreeViewConfiguration {
197
+ mode?: 'tree' | 'inspector';
198
+ presentation?: 'pane' | 'table';
199
+ nodes?: TreeNode[];
200
+ model?: any;
201
+ meta?: Record<string, MetaRule> | null;
202
+ columns?: Column[] | null;
203
+ theme?: string | Record<string, any>;
204
+ flatRoot?: boolean;
205
+ enforceMeta?: boolean;
206
+ filter?: boolean;
207
+ filterPlacement?: 'auto' | 'bar' | 'header';
208
+ markUpdated?: boolean;
209
+ editable?: boolean;
210
+ rowReorder?: boolean;
211
+ initialExpandDepth?: number;
212
+ rowHeight?: number;
213
+ indentWidth?: number;
214
+ headerHeight?: number;
215
+ iconResolver?: ((node: TreeNode) => string | Partial<TreeNode> | null | undefined) | null;
216
+ fontFamily?: string | null;
217
+ }
218
+ export const treeViewOptionNames: readonly (keyof TreeViewConfiguration)[];
219
+ export function validateTreeViewOptions(options: TreeViewConfiguration): void;
220
+
221
+ export class TreeView {
222
+ constructor(host: HTMLElement, options?: TreeViewConfiguration & Pick<TreeViewOptions, 'iconsBaseUrl' | 'iconRegistry' | 'nativeScrollbars'>);
223
+ readonly controller: TreeViewController;
224
+ readonly element: HTMLDivElement;
225
+ readonly canvas: HTMLCanvasElement;
226
+ readonly destroyed: boolean;
227
+ configure(options: TreeViewConfiguration): this;
228
+ flush(): void;
229
+ destroy(): void;
230
+ setData(nodes: TreeNode[]): void;
231
+ setModel(model: any, meta?: Record<string, MetaRule>): void;
232
+ setColumns(columns: Column[] | null): void;
233
+ setTheme(theme: string | Record<string, any>): void;
234
+ setDynamicState(patches: DynamicPatch[]): void;
235
+ setInspectorValue(path: string, value: any, options?: InspectorWriteOptions): boolean;
236
+ on<K extends keyof TreeViewEvents>(type: K, listener: (event: TreeViewEvent<K>) => void): () => void;
237
+ on(type: string, listener: (event: any) => void): () => void;
238
+ off(type: string, listener: (event: any) => void): void;
239
+ setFilter(query?: string | ((node: TreeNode, state: Record<string, any>) => boolean), options?: {caseSensitive?: boolean; wholeWord?: boolean}): void;
240
+ clearFilter(): void;
241
+ getRowOrder(parentId?: string | null): string[];
242
+ moveRow(id: string, index: number, options?: {source?: RowReorderDetail['source']}): boolean;
243
+ moveRowBy(id: string, offset: number, options?: {source?: RowReorderDetail['source']}): boolean;
244
+ getSelection(): string[];
245
+ setSelection(ids: string[]): void;
246
+ clearSelection(): void;
247
+ search(query: string, options?: Record<string, any>): any;
248
+ clearSearch(): void;
249
+ getSearchState(): TreeSearchState;
250
+ nextSearchResult(): string | null;
251
+ previousSearchResult(): string | null;
252
+ focusNode(id: string, options?: Record<string, any>): boolean;
253
+ scrollToNode(id: string, align?: TreeViewAlign): boolean;
254
+ expandAll(): void;
255
+ collapseAll(): void;
256
+ registerIcon(name: string, source: IconSource): any;
257
+ }
258
+
259
+ /** Public event details shared by the DOM view and the controller. */
260
+ export interface InspectorValueChange {
261
+ path: string;
262
+ oldValue: any;
263
+ newValue: any;
264
+ model: any;
265
+ nodeId: string;
266
+ editorType: string;
267
+ source: string;
268
+ }
269
+ export interface TreeSearchState {
270
+ query: string;
271
+ results: string[];
272
+ cursor: number;
273
+ current: string | null;
274
+ count: number;
275
+ }
276
+ export interface TreeViewEvents {
277
+ valuechange: InspectorValueChange;
278
+ modelchange: {model: any; meta?: Record<string, MetaRule>; path?: string; structural?: boolean; action?: string; value?: any} & Partial<InspectorValueChange>;
279
+ action: {path: string; label: string; nodeId: string; model: any; source: string};
280
+ rowreorder: RowReorderDetail;
281
+ selectionchange: {selection: string[]; focusedId: string | null};
282
+ focuschange: {nodeId: string | null};
283
+ filterchange: {query: string; options: {caseSensitive: boolean; wholeWord: boolean}; visibleRows: number; worker?: boolean};
284
+ searchchange: Omit<TreeSearchState, 'count'>;
285
+ nodeclick: {nodeId: string; row: any; originalEvent: any};
286
+ nodedblclick: {nodeId: string; row: any; originalEvent: any};
287
+ editablechange: {editable: boolean};
288
+ rowreorderchange: {enabled: boolean};
289
+ }
290
+ export type TreeViewEvent<K extends keyof TreeViewEvents> = {type: K; detail: TreeViewEvents[K]};
package/src/index.js CHANGED
@@ -4,3 +4,6 @@ export * from './inspector/index.js';
4
4
  export * from './renderers/index.js';
5
5
  export * from './tree-view-controller.js';
6
6
  export * from './benchmark/index.js';
7
+ export { builtinIconNames } from './assets/icons.js';
8
+ export { TreeView } from './tree-view.js';
9
+ export { treeViewOptionNames, validateTreeViewOptions } from './core/view-options.js';
@@ -1,2 +1,3 @@
1
1
  export * from './pointer-controller.js';
2
2
  export * from './tree-view-input-controller.js';
3
+ export * from './tree-tooltip.js';
@@ -0,0 +1,143 @@
1
+ let nextStatusId = 0;
2
+
3
+ /** Pointer interaction for the optional row-order column; persistence belongs to the host. */
4
+ export class RowReorderInput {
5
+ constructor(controller) {
6
+ this.controller = controller;
7
+ this.canvas = controller.canvas;
8
+ this.view = this.canvas.ownerDocument?.defaultView ?? globalThis.window;
9
+ this.gesture = null;
10
+ this.frame = null;
11
+ this.previousTouchAction = this.canvas.style.touchAction;
12
+ this.canvas.addEventListener('pointerdown', this.onDown);
13
+ this.canvas.addEventListener('keydown', this.onKey);
14
+ this.view.addEventListener('pointermove', this.onMove);
15
+ this.view.addEventListener('pointerup', this.onUp);
16
+ this.view.addEventListener('pointercancel', this.onCancel);
17
+ this.view.addEventListener('blur', this.cancel);
18
+ this.stops = ['datachange', 'filterchange', 'sortchange', 'rowreorderchange'].map(type => controller.on(type, () => {
19
+ this.cancel();
20
+ this.syncMode();
21
+ }));
22
+ this.stops.push(controller.on('rowreorder', event => {
23
+ if (!this.status) return;
24
+ const { nodeId, toIndex, siblingOrder } = event.detail;
25
+ const label = controller.model.index.getNode(nodeId)?.label ?? nodeId;
26
+ this.status.textContent = `${label}: position ${toIndex + 1} of ${siblingOrder.length}`;
27
+ }));
28
+ const doc = this.canvas.ownerDocument;
29
+ if (doc?.createElement && this.canvas.parentElement) {
30
+ this.status = doc.createElement('span');
31
+ this.status.id = `vtc-row-order-status-${nextStatusId++}`;
32
+ this.status.setAttribute('aria-live', 'polite');
33
+ Object.assign(this.status.style, { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' });
34
+ this.canvas.parentElement.appendChild(this.status);
35
+ }
36
+ this.syncMode();
37
+ }
38
+
39
+ syncMode() {
40
+ this.canvas.style.touchAction = this.controller.canReorderRows() ? 'none' : this.previousTouchAction;
41
+ }
42
+
43
+ onDown = event => {
44
+ if (event.button !== 0 || !this.controller.canReorderRows() || this.gesture) return;
45
+ const point = this.point(event);
46
+ const hit = this.controller.hitTest(point.x, point.y);
47
+ if (!hit?.row || !['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
48
+ this.canvas.focus();
49
+ this.controller.cellEditor?.close?.();
50
+ this.controller.focusedId = hit.row.nodeId;
51
+ this.gesture = { id: hit.row.nodeId, part: hit.part, pointerId: event.pointerId,
52
+ startX: point.x, startY: point.y, point, dragging: false };
53
+ this.canvas.setPointerCapture?.(event.pointerId);
54
+ event.preventDefault();
55
+ };
56
+
57
+ onMove = event => {
58
+ const gesture = this.gesture;
59
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
60
+ gesture.point = this.point(event);
61
+ if (gesture.part !== 'rowDrag') return;
62
+ if (!gesture.dragging && Math.hypot(gesture.point.x - gesture.startX, gesture.point.y - gesture.startY) < 5) return;
63
+ gesture.dragging = true;
64
+ this.canvas.style.cursor = 'grabbing';
65
+ this.updateTarget();
66
+ this.scheduleScroll();
67
+ event.preventDefault();
68
+ };
69
+
70
+ onUp = event => {
71
+ const gesture = this.gesture;
72
+ if (!gesture || event.pointerId !== gesture.pointerId) return;
73
+ gesture.point = this.point(event);
74
+ const target = gesture.dragging ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null;
75
+ const hit = this.controller.hitTest(gesture.point.x, gesture.point.y);
76
+ this.cancel();
77
+ if (target) this.controller.moveRow(gesture.id, target.index, { source: 'pointer' });
78
+ else if (!gesture.dragging && hit?.row?.nodeId === gesture.id && hit.part === gesture.part) {
79
+ if (gesture.part === 'rowUp') this.controller.moveRowBy(gesture.id, -1, { source: 'button' });
80
+ if (gesture.part === 'rowDown') this.controller.moveRowBy(gesture.id, 1, { source: 'button' });
81
+ }
82
+ };
83
+
84
+ onCancel = event => { if (this.gesture?.pointerId === event.pointerId) this.cancel(); };
85
+ onKey = event => {
86
+ if (event.key === 'Escape' && this.gesture) {
87
+ this.cancel();
88
+ event.preventDefault();
89
+ }
90
+ };
91
+
92
+ point(event) {
93
+ const rect = this.canvas.getBoundingClientRect();
94
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
95
+ }
96
+
97
+ updateTarget() {
98
+ const gesture = this.gesture;
99
+ this.controller.setRowDrop(gesture ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null);
100
+ }
101
+
102
+ scheduleScroll() {
103
+ if (this.frame !== null || !this.gesture?.dragging) return;
104
+ const viewport = this.controller.viewport;
105
+ const y = this.gesture.point.y - viewport.renderInsetY;
106
+ const top = viewport.headerHeight;
107
+ const bottom = top + viewport.rowViewportHeight;
108
+ const delta = y < top + 24 ? -10 : y > bottom - 24 ? 10 : 0;
109
+ if (!delta) return;
110
+ const schedule = this.view.requestAnimationFrame?.bind(this.view) ?? (callback => setTimeout(callback, 16));
111
+ this.frame = schedule(() => {
112
+ this.frame = null;
113
+ if (!this.gesture?.dragging) return;
114
+ const previous = viewport.scrollY;
115
+ this.controller.scrollBy(0, delta);
116
+ this.updateTarget();
117
+ if (viewport.scrollY !== previous) this.scheduleScroll();
118
+ });
119
+ }
120
+
121
+ cancel = () => {
122
+ const gesture = this.gesture;
123
+ this.gesture = null;
124
+ if (this.frame !== null) (this.view.cancelAnimationFrame?.bind(this.view) ?? clearTimeout)(this.frame);
125
+ this.frame = null;
126
+ if (gesture && this.canvas.hasPointerCapture?.(gesture.pointerId)) this.canvas.releasePointerCapture(gesture.pointerId);
127
+ this.canvas.style.cursor = '';
128
+ this.controller.setRowDrop(null);
129
+ };
130
+
131
+ destroy() {
132
+ this.cancel();
133
+ this.canvas.removeEventListener('pointerdown', this.onDown);
134
+ this.canvas.removeEventListener('keydown', this.onKey);
135
+ this.view.removeEventListener('pointermove', this.onMove);
136
+ this.view.removeEventListener('pointerup', this.onUp);
137
+ this.view.removeEventListener('pointercancel', this.onCancel);
138
+ this.view.removeEventListener('blur', this.cancel);
139
+ this.canvas.style.touchAction = this.previousTouchAction;
140
+ for (const stop of this.stops) stop();
141
+ this.status?.remove();
142
+ }
143
+ }
@@ -0,0 +1,55 @@
1
+ /** A view of controller filter state. No duplicated query or toggle state. */
2
+ export class TreeFilterBar {
3
+ constructor(controller, document) {
4
+ this.controller = controller;
5
+ this.element = document.createElement('div');
6
+ this.element.className = 'vtc-filter';
7
+ this.input = document.createElement('input');
8
+ this.input.type = 'search';
9
+ this.input.placeholder = 'Filter';
10
+ this.input.setAttribute('aria-label', 'Filter rows');
11
+ this.caseButton = this.button(document, 'Aa', 'Match case');
12
+ this.wholeButton = this.button(document, 'W', 'Match whole word');
13
+ this.element.append(this.input, this.caseButton, this.wholeButton);
14
+ this.input.addEventListener('input', this.onInput);
15
+ this.caseButton.addEventListener('click', this.onCase);
16
+ this.wholeButton.addEventListener('click', this.onWhole);
17
+ this.stop = controller.on('filterchange', () => this.sync());
18
+ this.sync();
19
+ }
20
+
21
+ button(document, label, title) {
22
+ const button = document.createElement('button');
23
+ button.type = 'button';
24
+ button.textContent = label;
25
+ button.title = title;
26
+ button.setAttribute('aria-label', title);
27
+ return button;
28
+ }
29
+
30
+ onInput = () => this.controller.setFilter(this.input.value, this.controller.filterOptions);
31
+ onCase = () => this.toggle('caseSensitive');
32
+ onWhole = () => this.toggle('wholeWord');
33
+
34
+ toggle(key) {
35
+ const options = this.controller.filterOptions;
36
+ this.controller.setFilter(this.input.value, { ...options, [key]: !options[key] });
37
+ this.input.focus();
38
+ }
39
+
40
+ sync() {
41
+ this.input.value = this.controller.filterQuery;
42
+ this.caseButton.setAttribute('aria-pressed', String(this.controller.filterOptions.caseSensitive));
43
+ this.wholeButton.setAttribute('aria-pressed', String(this.controller.filterOptions.wholeWord));
44
+ }
45
+
46
+ setVisible(visible) { this.element.hidden = !visible; }
47
+
48
+ destroy() {
49
+ this.stop();
50
+ this.input.removeEventListener('input', this.onInput);
51
+ this.caseButton.removeEventListener('click', this.onCase);
52
+ this.wholeButton.removeEventListener('click', this.onWhole);
53
+ this.element.remove();
54
+ }
55
+ }
@@ -0,0 +1,55 @@
1
+ /** DOM tooltip shared by framework adapters and standalone canvas hosts. */
2
+ export class TreeTooltip {
3
+ constructor({ controller, host = controller.canvas?.parentElement }) {
4
+ this.controller = controller;
5
+ this.canvas = controller.canvas;
6
+ this.host = host;
7
+ if (!this.canvas || !host?.ownerDocument) throw new Error('TreeTooltip requires a mounted canvas and host');
8
+ this.element = host.ownerDocument.createElement('div');
9
+ this.element.className = 'virtual-tree-canvas-tooltip';
10
+ this.element.setAttribute('role', 'tooltip');
11
+ Object.assign(this.element.style, {
12
+ position: 'absolute', display: 'none', pointerEvents: 'none', zIndex: '20',
13
+ maxWidth: 'calc(100% - 16px)', padding: '6px 8px', borderRadius: '5px',
14
+ background: 'var(--vtc-tooltip-background, #111827)', color: 'var(--vtc-tooltip-color, #e5e7eb)',
15
+ border: '1px solid var(--vtc-tooltip-border, #374151)',
16
+ font: '12px/1.35 var(--font-family, ui-monospace, monospace)', overflowWrap: 'anywhere'
17
+ });
18
+ host.appendChild(this.element);
19
+ this.canvas.addEventListener('mousemove', this.onMove);
20
+ this.canvas.addEventListener('mouseleave', this.hide);
21
+ this.canvas.addEventListener('wheel', this.hide);
22
+ this.canvas.addEventListener('pointerdown', this.hide);
23
+ this.stops = ['viewportchange', 'filterchange', 'datachange', 'rowdrag'].map(type => controller.on(type, this.hide));
24
+ }
25
+
26
+ onMove = event => {
27
+ const rect = this.canvas.getBoundingClientRect();
28
+ const hit = this.controller.hitTest(event.clientX - rect.left, event.clientY - rect.top);
29
+ let text;
30
+ if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) {
31
+ text = this.controller.canReorderRows()
32
+ ? { rowDrag: 'Drag to reorder. Alt + Up/Down also moves the focused row.', rowUp: 'Move up', rowDown: 'Move down' }[hit.part]
33
+ : 'Clear sorting and filtering to reorder rows.';
34
+ } else text = this.controller.getTooltipForHit(hit)?.text;
35
+ if (!text || this.controller.rowReorderInput?.gesture) return this.hide();
36
+ const hostRect = this.host.getBoundingClientRect();
37
+ this.element.textContent = text;
38
+ this.element.style.display = 'block';
39
+ const x = event.clientX - hostRect.left + 12;
40
+ const y = event.clientY - hostRect.top + 14;
41
+ this.element.style.left = `${Math.max(8, Math.min(x, hostRect.width - this.element.offsetWidth - 8))}px`;
42
+ this.element.style.top = `${Math.max(8, Math.min(y, hostRect.height - this.element.offsetHeight - 8))}px`;
43
+ };
44
+
45
+ hide = () => { this.element.style.display = 'none'; };
46
+
47
+ destroy() {
48
+ this.canvas.removeEventListener('mousemove', this.onMove);
49
+ this.canvas.removeEventListener('mouseleave', this.hide);
50
+ this.canvas.removeEventListener('wheel', this.hide);
51
+ this.canvas.removeEventListener('pointerdown', this.hide);
52
+ for (const stop of this.stops) stop();
53
+ this.element.remove();
54
+ }
55
+ }
@@ -43,6 +43,7 @@ export class TreeViewInputController {
43
43
  };
44
44
 
45
45
  #onMouseMove = (event) => {
46
+ if (this.controller.rowReorderInput?.gesture) return;
46
47
  if (this.resizeDrag) {
47
48
  const rect = this.canvas.getBoundingClientRect();
48
49
  const clientX = event.clientX - rect.left;
@@ -73,6 +74,7 @@ export class TreeViewInputController {
73
74
  this.canvas.focus();
74
75
  const hit = this.#hitTest(event);
75
76
  if (!hit) return;
77
+ if (['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
76
78
  if (hit.area === 'header') {
77
79
  if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
78
80
  if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller.sortBy(hit.column.id);
@@ -98,6 +100,7 @@ export class TreeViewInputController {
98
100
 
99
101
  #onMouseDown = (event) => {
100
102
  const hit = this.#hitTest(event);
103
+ if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) return;
101
104
  this.controller.setActiveHit(hit);
102
105
  if (this.cellEditor?.handlePointerDown(event, hit)) {
103
106
  event.preventDefault();
@@ -135,6 +138,8 @@ export class TreeViewInputController {
135
138
  }
136
139
 
137
140
  function cursorForHit(hit) {
141
+ if (hit?.part === 'rowDrag') return 'grab';
142
+ if (hit?.part === 'rowUp' || hit?.part === 'rowDown') return 'pointer';
138
143
  if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
139
144
  if (hit?.area === 'header' && hit.part === 'filter') return 'text';
140
145
  if (hit?.area !== 'row') return '';
@@ -1,3 +1,5 @@
1
+ import { numericLayout } from './numeric-layout.js';
2
+
1
3
  export class CellEditorManager {
2
4
  constructor({ controller, host = null }) {
3
5
  if (!controller) throw new TypeError('CellEditorManager requires a TreeViewController');
@@ -5,13 +7,24 @@ export class CellEditorManager {
5
7
  this.controller = controller;
6
8
  this.canvas = controller.canvas;
7
9
  this.host = host ?? controller.canvas.parentElement ?? document.body;
10
+ this.editable = true;
8
11
  this.overlay = null;
9
12
  this.rangeDrag = null;
13
+ this.overlayKind = null;
14
+ this.stopFilter = controller.on('filterchange', ({ detail }) => {
15
+ if (this.overlayKind === 'filter' && this.overlay) this.overlay.value = detail.query;
16
+ });
10
17
  this.onMouseMove = this.#onMouseMove.bind(this);
11
18
  this.onMouseUp = this.#onMouseUp.bind(this);
12
19
  }
13
20
 
21
+ setEditable(enabled) {
22
+ this.editable = enabled;
23
+ if (!enabled) this.close();
24
+ }
25
+
14
26
  destroy() {
27
+ this.stopFilter();
15
28
  this.#removeOverlay();
16
29
  window.removeEventListener('mousemove', this.onMouseMove);
17
30
  window.removeEventListener('mouseup', this.onMouseUp);
@@ -25,7 +38,7 @@ export class CellEditorManager {
25
38
  }
26
39
 
27
40
  handlePointerDown(event, hit) {
28
- if (!this.#isEditableHit(hit)) return false;
41
+ if (!this.editable || !this.#isEditableHit(hit)) return false;
29
42
  const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
30
43
  if (!data || data.readonly || data.disabled) return false;
31
44
  if (data.editorType === 'range' && hit.part === 'range') {
@@ -44,7 +57,7 @@ export class CellEditorManager {
44
57
  }
45
58
 
46
59
  handleClick(event, hit) {
47
- if (!this.#isEditableHit(hit)) return false;
60
+ if (!this.editable || !this.#isEditableHit(hit)) return false;
48
61
  const node = this.controller.model.nodes[hit.row.nodeIndex];
49
62
  const data = node?.data;
50
63
  if (!data || data.disabled) return false;
@@ -81,6 +94,7 @@ export class CellEditorManager {
81
94
  const hostRect = this.host.getBoundingClientRect();
82
95
  const element = createEditorElement(data);
83
96
  element.className = `vtc-editor vtc-editor-${data.editorType}`;
97
+ element.setAttribute('aria-label', [node.label, numericLayout(rect.width, data).unit].filter(Boolean).join(' '));
84
98
  Object.assign(element.style, {
85
99
  position: 'absolute',
86
100
  left: `${rect.x - hostRect.left}px`,
@@ -105,7 +119,7 @@ export class CellEditorManager {
105
119
  });
106
120
  let committed = false;
107
121
  const commit = () => {
108
- if (committed) return;
122
+ if (committed || this.overlay !== element) return;
109
123
  committed = true;
110
124
  const nextValue = parseEditorValue(element, data);
111
125
  this.controller.updateInspectorValue(node.id, nextValue, data.editorType);
@@ -122,6 +136,7 @@ export class CellEditorManager {
122
136
  ensureOverlayHost(this.host);
123
137
  this.host.append(element);
124
138
  this.overlay = element;
139
+ this.overlayKind = 'value';
125
140
  element.focus({ preventScroll: true });
126
141
  element.select?.();
127
142
  if (options.showPicker && element.showPicker) {
@@ -177,6 +192,7 @@ export class CellEditorManager {
177
192
  ensureOverlayHost(this.host);
178
193
  this.host.append(element);
179
194
  this.overlay = element;
195
+ this.overlayKind = 'filter';
180
196
  element.focus({ preventScroll: true });
181
197
  element.select();
182
198
  }
@@ -208,34 +224,36 @@ export class CellEditorManager {
208
224
  }
209
225
 
210
226
  #isEditableHit(hit) {
211
- return hit?.area === 'row' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
227
+ return hit?.area === 'row' && hit.part !== 'unit' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
212
228
  }
213
229
 
214
- #overlayRect(hit) {
215
- if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
230
+ #editorContentRect(hit) {
216
231
  const rect = this.controller.getCellClientRect(hit);
232
+ if (hit.column?.kind !== 'inspectorPane') {
233
+ return { x: rect.x + 10, y: rect.y + 4, width: Math.max(24, rect.width - 20), height: rect.height - 8 };
234
+ }
217
235
  const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
218
236
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
219
237
  const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
220
- if (hit.part === 'number') {
221
- const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
222
- return { x: rect.x + editorLeft + editorWidth - valueWidth - 10, y: rect.y + 4, width: valueWidth, height: rect.height - 8 };
238
+ return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: Math.max(24, editorWidth - 20), height: rect.height - 8 };
239
+ }
240
+
241
+ #overlayRect(hit) {
242
+ const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
243
+ const rect = this.#editorContentRect(hit);
244
+ const layout = numericLayout(rect.width, data);
245
+ if (hit.part === 'number' && data.editorType === 'range') {
246
+ return { ...rect, x: rect.x + layout.numberLeft, width: layout.valueWidth };
223
247
  }
224
- return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: editorWidth - 20, height: rect.height - 8 };
248
+ if (layout.unit) return { ...rect, width: layout.contentWidth };
249
+ if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
250
+ return rect;
225
251
  }
226
252
 
227
253
  #rangeBarRect(hit) {
228
- const rect = this.controller.getCellClientRect(hit);
229
- if (hit.column?.kind === 'inspectorPane') {
230
- const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
231
- const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
232
- const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
233
- const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
234
- const barWidth = Math.max(24, editorWidth - 20 - valueWidth - 8);
235
- return { x: rect.x + editorLeft + 10, y: rect.y + rect.height / 2 - 4, width: barWidth, height: 8 };
236
- }
237
- const valueWidth = Math.min(64, Math.max(42, (rect.width - 20) * 0.28));
238
- return { x: rect.x + 10, y: rect.y + rect.height / 2 - 4, width: Math.max(24, rect.width - 20 - valueWidth - 8), height: 8 };
254
+ const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
255
+ const rect = this.#editorContentRect(hit);
256
+ return { ...rect, y: rect.y + rect.height / 2 - 4, width: numericLayout(rect.width, data).barWidth, height: 8 };
239
257
  }
240
258
 
241
259
  #clampRectToHost(rect, inset = 0) {
@@ -252,8 +270,10 @@ export class CellEditorManager {
252
270
  }
253
271
 
254
272
  #removeOverlay() {
255
- this.overlay?.remove();
273
+ const overlay = this.overlay;
256
274
  this.overlay = null;
275
+ this.overlayKind = null;
276
+ overlay?.remove();
257
277
  }
258
278
 
259
279
  #toggleRangeMinMax(node, data) {
@@ -28,6 +28,11 @@ export function formatInspectorValue(value, meta = {}) {
28
28
  if (value === null) return 'null';
29
29
  if (value === undefined) return 'undefined';
30
30
  if (typeof value === 'object') return Array.isArray(value) ? `Array(${value.length})` : 'Object';
31
- if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
31
+ if (typeof value === 'number') {
32
+ if (Number.isFinite(value) && Number.isInteger(meta.precision) && meta.precision >= 0 && meta.precision <= 20) {
33
+ return value.toFixed(meta.precision);
34
+ }
35
+ return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
36
+ }
32
37
  return String(value);
33
38
  }