virtual-tree-canvas 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +335 -0
  3. package/package.json +40 -0
  4. package/src/assets/.gitkeep +1 -0
  5. package/src/benchmark/benchmark-stats.js +99 -0
  6. package/src/benchmark/index.js +2 -0
  7. package/src/benchmark/renderer-state.js +62 -0
  8. package/src/core/asset-manager.js +25 -0
  9. package/src/core/culling.js +12 -0
  10. package/src/core/event-emitter.js +21 -0
  11. package/src/core/icon-registry.js +173 -0
  12. package/src/core/index.js +19 -0
  13. package/src/core/layout-engine.js +64 -0
  14. package/src/core/patch-batcher.js +29 -0
  15. package/src/core/scene.js +13 -0
  16. package/src/core/search-index.js +70 -0
  17. package/src/core/selection-manager.js +38 -0
  18. package/src/core/theme-manager.js +162 -0
  19. package/src/core/tree-column-model.js +151 -0
  20. package/src/core/tree-expansion-manager.js +67 -0
  21. package/src/core/tree-index.js +79 -0
  22. package/src/core/tree-model.js +80 -0
  23. package/src/core/tree-view-viewport.js +69 -0
  24. package/src/core/tree-worker-client.js +50 -0
  25. package/src/core/tree-worker-operations.js +152 -0
  26. package/src/core/types.js +44 -0
  27. package/src/core/viewport.js +66 -0
  28. package/src/core/visible-row-model.js +137 -0
  29. package/src/index.js +6 -0
  30. package/src/input/index.js +2 -0
  31. package/src/input/pointer-controller.js +66 -0
  32. package/src/input/tree-view-input-controller.js +235 -0
  33. package/src/inspector/cell-editor-manager.js +256 -0
  34. package/src/inspector/editor-resolver.js +33 -0
  35. package/src/inspector/index.js +4 -0
  36. package/src/inspector/model-inspector-builder.js +139 -0
  37. package/src/inspector/model-path.js +48 -0
  38. package/src/renderers/canvas2d-renderer.js +120 -0
  39. package/src/renderers/index.js +3 -0
  40. package/src/renderers/renderer.js +10 -0
  41. package/src/renderers/tree-row-renderer.js +443 -0
  42. package/src/tree-view-controller.js +825 -0
  43. package/src/workers/tree-worker.js +59 -0
@@ -0,0 +1,66 @@
1
+ export class TreeViewport extends EventTarget {
2
+ constructor() {
3
+ super();
4
+ this.x = 0;
5
+ this.y = 0;
6
+ this.zoom = 1;
7
+ this.width = 1;
8
+ this.height = 1;
9
+ this.minZoom = 0.08;
10
+ this.maxZoom = 4;
11
+ }
12
+
13
+ resize(width, height) {
14
+ this.width = Math.max(1, width);
15
+ this.height = Math.max(1, height);
16
+ this.dispatchEvent(new Event('change'));
17
+ }
18
+
19
+ pan(dx, dy) {
20
+ this.x += dx / this.zoom;
21
+ this.y += dy / this.zoom;
22
+ this.dispatchEvent(new Event('change'));
23
+ }
24
+
25
+ zoomAt(screenX, screenY, factor) {
26
+ const before = this.screenToWorld(screenX, screenY);
27
+ this.zoom = clamp(this.zoom * factor, this.minZoom, this.maxZoom);
28
+ const after = this.screenToWorld(screenX, screenY);
29
+ this.x += before.x - after.x;
30
+ this.y += before.y - after.y;
31
+ this.dispatchEvent(new Event('change'));
32
+ }
33
+
34
+ focus(bounds) {
35
+ this.x = bounds.x + bounds.width / 2 - this.width / (2 * this.zoom);
36
+ this.y = bounds.y + bounds.height / 2 - this.height / (2 * this.zoom);
37
+ this.dispatchEvent(new Event('change'));
38
+ }
39
+
40
+ getWorldBounds(padding = 120) {
41
+ return {
42
+ x: this.x - padding / this.zoom,
43
+ y: this.y - padding / this.zoom,
44
+ width: this.width / this.zoom + (padding * 2) / this.zoom,
45
+ height: this.height / this.zoom + (padding * 2) / this.zoom,
46
+ };
47
+ }
48
+
49
+ screenToWorld(screenX, screenY) {
50
+ return {
51
+ x: this.x + screenX / this.zoom,
52
+ y: this.y + screenY / this.zoom,
53
+ };
54
+ }
55
+
56
+ worldToScreen(worldX, worldY) {
57
+ return {
58
+ x: (worldX - this.x) * this.zoom,
59
+ y: (worldY - this.y) * this.zoom,
60
+ };
61
+ }
62
+ }
63
+
64
+ function clamp(value, min, max) {
65
+ return Math.max(min, Math.min(max, value));
66
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @typedef {Object} TreeRow
3
+ * @property {string} nodeId
4
+ * @property {number} nodeIndex
5
+ * @property {number} depth
6
+ * @property {number} rowIndex
7
+ * @property {number} y
8
+ * @property {number} height
9
+ * @property {boolean} expanded
10
+ * @property {boolean} hasChildren
11
+ */
12
+
13
+ export class VisibleRowModel extends EventTarget {
14
+ /**
15
+ * @param {{
16
+ * model: import('./tree-model.js').TreeModel,
17
+ * expansion: import('./tree-expansion-manager.js').TreeExpansionManager,
18
+ * rowHeight?: number,
19
+ * indentWidth?: number
20
+ * }} options
21
+ */
22
+ constructor({ model, expansion, rowHeight = 28, indentWidth = 18 }) {
23
+ super();
24
+ this.model = model;
25
+ this.expansion = expansion;
26
+ this.rowHeight = rowHeight;
27
+ this.indentWidth = indentWidth;
28
+ /** @type {TreeRow[]} */
29
+ this.rows = [];
30
+ /** @type {Map<string, number>} */
31
+ this.rowIndexById = new Map();
32
+ this.contentWidth = 0;
33
+ this.contentHeight = 0;
34
+ this.sortComparator = null;
35
+ this.filterPredicate = null;
36
+ }
37
+
38
+ setSortComparator(comparator) {
39
+ this.sortComparator = comparator;
40
+ }
41
+
42
+ setFilterPredicate(predicate) {
43
+ this.filterPredicate = predicate;
44
+ }
45
+
46
+ applyRows({ rows, contentHeight, contentWidth }) {
47
+ this.rows = rows;
48
+ this.rowIndexById.clear();
49
+ for (const row of rows) this.rowIndexById.set(row.nodeId, row.rowIndex);
50
+ this.contentHeight = contentHeight;
51
+ this.contentWidth = contentWidth;
52
+ this.dispatchEvent(new Event('change'));
53
+ }
54
+
55
+ rebuild() {
56
+ this.rows = [];
57
+ this.rowIndexById.clear();
58
+ let maxDepth = 0;
59
+ const includeCache = new Map();
60
+
61
+ const subtreeIncluded = (id) => {
62
+ if (!this.filterPredicate) return true;
63
+ if (includeCache.has(id)) return includeCache.get(id);
64
+ const node = this.model.index.getNode(id);
65
+ const state = this.model.dynamicState.get(id) ?? {};
66
+ const ownMatch = node ? this.filterPredicate(node, state) : false;
67
+ const childMatch = this.model.index.getChildren(id).some((childId) => subtreeIncluded(childId));
68
+ const included = ownMatch || childMatch;
69
+ includeCache.set(id, included);
70
+ return included;
71
+ };
72
+
73
+ const sortedChildren = (id) => {
74
+ const children = this.model.index.getChildren(id).filter((childId) => subtreeIncluded(childId));
75
+ if (!this.sortComparator) return children;
76
+ return children.slice().sort((aId, bId) => {
77
+ const a = this.model.index.getNode(aId);
78
+ const b = this.model.index.getNode(bId);
79
+ return this.sortComparator(a, b, aId, bId);
80
+ });
81
+ };
82
+
83
+ const visit = (id, depth) => {
84
+ if (!subtreeIncluded(id)) return;
85
+ const nodeIndex = this.model.index.idToIndex.get(id);
86
+ if (nodeIndex === undefined) return;
87
+ const hasChildren = this.expansion.hasChildren(id);
88
+ const expanded = this.expansion.isExpanded(id);
89
+ const rowIndex = this.rows.length;
90
+ this.rows.push({
91
+ nodeId: id,
92
+ nodeIndex,
93
+ depth,
94
+ rowIndex,
95
+ y: rowIndex * this.rowHeight,
96
+ height: this.rowHeight,
97
+ expanded,
98
+ hasChildren,
99
+ });
100
+ this.rowIndexById.set(id, rowIndex);
101
+ maxDepth = Math.max(maxDepth, depth);
102
+ if (!expanded && !this.filterPredicate) return;
103
+ for (const childId of sortedChildren(id)) visit(childId, depth + 1);
104
+ };
105
+
106
+ const roots = this.model.index.roots.filter((rootId) => subtreeIncluded(rootId));
107
+ if (this.sortComparator) {
108
+ roots.sort((aId, bId) => this.sortComparator(this.model.index.getNode(aId), this.model.index.getNode(bId), aId, bId));
109
+ }
110
+ for (const rootId of roots) visit(rootId, 0);
111
+ this.contentHeight = this.rows.length * this.rowHeight;
112
+ this.contentWidth = Math.max(640, (maxDepth + 1) * this.indentWidth + 520);
113
+ this.dispatchEvent(new Event('change'));
114
+ }
115
+
116
+ /** @param {string} id */
117
+ getRowById(id) {
118
+ const rowIndex = this.rowIndexById.get(id);
119
+ return rowIndex === undefined ? null : this.rows[rowIndex];
120
+ }
121
+
122
+ /** @param {number} rowIndex */
123
+ getRow(rowIndex) {
124
+ return this.rows[rowIndex] ?? null;
125
+ }
126
+
127
+ /**
128
+ * @param {import('./tree-view-viewport.js').TreeViewViewport} viewport
129
+ * @param {number} overscan
130
+ */
131
+ getVisibleRange(viewport, overscan = 6) {
132
+ const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
133
+ const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight) - overscan);
134
+ const last = Math.min(this.rows.length - 1, Math.ceil((viewport.scrollY + rowViewportHeight) / this.rowHeight) + overscan);
135
+ return { first, last, count: last >= first ? last - first + 1 : 0 };
136
+ }
137
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from './core/index.js';
2
+ export * from './input/index.js';
3
+ export * from './inspector/index.js';
4
+ export * from './renderers/index.js';
5
+ export * from './tree-view-controller.js';
6
+ export * from './benchmark/index.js';
@@ -0,0 +1,2 @@
1
+ export * from './pointer-controller.js';
2
+ export * from './tree-view-input-controller.js';
@@ -0,0 +1,66 @@
1
+ export class PointerController {
2
+ constructor({ canvas, viewport, renderer, onHover, onClick }) {
3
+ this.canvas = canvas;
4
+ this.viewport = viewport;
5
+ this.renderer = renderer;
6
+ this.onHover = onHover;
7
+ this.onClick = onClick;
8
+ this.dragging = false;
9
+ this.lastX = 0;
10
+ this.lastY = 0;
11
+ this.downX = 0;
12
+ this.downY = 0;
13
+
14
+ canvas.addEventListener('pointerdown', this.#onPointerDown);
15
+ canvas.addEventListener('pointermove', this.#onPointerMove);
16
+ canvas.addEventListener('pointerup', this.#onPointerUp);
17
+ canvas.addEventListener('pointercancel', this.#onPointerUp);
18
+ canvas.addEventListener('wheel', this.#onWheel, { passive: false });
19
+ }
20
+
21
+ destroy() {
22
+ this.canvas.removeEventListener('pointerdown', this.#onPointerDown);
23
+ this.canvas.removeEventListener('pointermove', this.#onPointerMove);
24
+ this.canvas.removeEventListener('pointerup', this.#onPointerUp);
25
+ this.canvas.removeEventListener('pointercancel', this.#onPointerUp);
26
+ this.canvas.removeEventListener('wheel', this.#onWheel);
27
+ }
28
+
29
+ #onPointerDown = (event) => {
30
+ this.dragging = true;
31
+ this.lastX = event.clientX;
32
+ this.lastY = event.clientY;
33
+ this.downX = event.clientX;
34
+ this.downY = event.clientY;
35
+ this.canvas.setPointerCapture(event.pointerId);
36
+ };
37
+
38
+ #onPointerMove = (event) => {
39
+ const rect = this.canvas.getBoundingClientRect();
40
+ const x = event.clientX - rect.left;
41
+ const y = event.clientY - rect.top;
42
+ if (this.dragging && event.buttons === 1) {
43
+ this.viewport.pan(this.lastX - event.clientX, this.lastY - event.clientY);
44
+ }
45
+ this.lastX = event.clientX;
46
+ this.lastY = event.clientY;
47
+ const world = this.viewport.screenToWorld(x, y);
48
+ this.onHover?.(this.renderer.pick?.(world.x, world.y) ?? null);
49
+ };
50
+
51
+ #onPointerUp = (event) => {
52
+ const moved = Math.hypot(event.clientX - this.downX, event.clientY - this.downY);
53
+ this.dragging = false;
54
+ const rect = this.canvas.getBoundingClientRect();
55
+ const world = this.viewport.screenToWorld(event.clientX - rect.left, event.clientY - rect.top);
56
+ const id = this.renderer.pick?.(world.x, world.y) ?? null;
57
+ if (id && moved < 4) this.onClick?.(id, event);
58
+ };
59
+
60
+ #onWheel = (event) => {
61
+ event.preventDefault();
62
+ const rect = this.canvas.getBoundingClientRect();
63
+ const factor = Math.exp(-event.deltaY * 0.0012);
64
+ this.viewport.zoomAt(event.clientX - rect.left, event.clientY - rect.top, factor);
65
+ };
66
+ }
@@ -0,0 +1,235 @@
1
+ export class TreeViewInputController {
2
+ /**
3
+ * @param {{
4
+ * canvas: HTMLCanvasElement,
5
+ * viewport: import('../core/tree-view-viewport.js').TreeViewViewport,
6
+ * rowModel: import('../core/visible-row-model.js').VisibleRowModel,
7
+ * expansion: import('../core/tree-expansion-manager.js').TreeExpansionManager,
8
+ * selection: import('../core/selection-manager.js').TreeSelectionManager,
9
+ * controller?: import('../tree-view-controller.js').TreeViewController,
10
+ * onRowsChanged?: () => void,
11
+ * onSelectionChanged?: () => void,
12
+ * onHoverChanged?: (id: string | null) => void
13
+ * }} options
14
+ */
15
+ constructor(options) {
16
+ Object.assign(this, options);
17
+ this.anchorRowIndex = null;
18
+ this.hoveredId = null;
19
+ this.resizeDrag = null;
20
+ this.cellEditor = options.cellEditor ?? null;
21
+ this.canvas.tabIndex = 0;
22
+
23
+ this.canvas.addEventListener('wheel', this.#onWheel, { passive: false });
24
+ this.canvas.addEventListener('mousedown', this.#onMouseDown);
25
+ this.canvas.addEventListener('mousemove', this.#onMouseMove);
26
+ window.addEventListener('mouseup', this.#onMouseUp);
27
+ this.canvas.addEventListener('mouseleave', this.#onMouseLeave);
28
+ this.canvas.addEventListener('click', this.#onClick);
29
+ this.canvas.addEventListener('dblclick', this.#onDoubleClick);
30
+ this.canvas.addEventListener('keydown', this.#onKeyDown);
31
+ }
32
+
33
+ destroy() {
34
+ this.canvas.removeEventListener('wheel', this.#onWheel);
35
+ this.canvas.removeEventListener('mousedown', this.#onMouseDown);
36
+ this.canvas.removeEventListener('mousemove', this.#onMouseMove);
37
+ window.removeEventListener('mouseup', this.#onMouseUp);
38
+ this.canvas.removeEventListener('mouseleave', this.#onMouseLeave);
39
+ this.canvas.removeEventListener('click', this.#onClick);
40
+ this.canvas.removeEventListener('dblclick', this.#onDoubleClick);
41
+ this.canvas.removeEventListener('keydown', this.#onKeyDown);
42
+ }
43
+
44
+ #onWheel = (event) => {
45
+ event.preventDefault();
46
+ if (this.controller) {
47
+ this.controller.scrollBy(event.shiftKey ? event.deltaY : event.deltaX, event.deltaY);
48
+ return;
49
+ }
50
+ this.viewport.scrollBy(event.shiftKey ? event.deltaY : event.deltaX, event.deltaY);
51
+ };
52
+
53
+ #onMouseMove = (event) => {
54
+ if (this.resizeDrag) {
55
+ const rect = this.canvas.getBoundingClientRect();
56
+ const clientX = event.clientX - rect.left;
57
+ const nextWidth = this.resizeDrag.startWidth + (clientX - this.resizeDrag.startX);
58
+ this.controller?.resizeColumn(this.resizeDrag.columnId, nextWidth);
59
+ event.preventDefault();
60
+ return;
61
+ }
62
+ const hit = this.#hitTest(event);
63
+ this.canvas.style.cursor = hit?.area === 'header' && hit.part === 'resize' ? 'col-resize' : '';
64
+ const id = hit?.row?.nodeId ?? null;
65
+ if (id === this.hoveredId) return;
66
+ this.hoveredId = id;
67
+ this.controller?.setHover(id);
68
+ this.onHoverChanged?.(id);
69
+ };
70
+
71
+ #onMouseLeave = () => {
72
+ if (!this.resizeDrag) this.canvas.style.cursor = '';
73
+ this.hoveredId = null;
74
+ this.controller?.setHover(null);
75
+ this.onHoverChanged?.(null);
76
+ };
77
+
78
+ #onClick = (event) => {
79
+ this.canvas.focus();
80
+ const hit = this.#hitTest(event);
81
+ if (!hit) return;
82
+ if (hit.area === 'header') {
83
+ if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
84
+ if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller?.sortBy(hit.column.id);
85
+ return;
86
+ }
87
+ if (hit.part === 'chevron' && hit.row.hasChildren) {
88
+ if (this.controller) {
89
+ this.controller.toggle(hit.row.nodeId);
90
+ this.onRowsChanged?.();
91
+ return;
92
+ }
93
+ this.expansion.toggle(hit.row.nodeId);
94
+ this.rowModel.rebuild();
95
+ this.viewport.setContentSize(this.rowModel.contentWidth, this.rowModel.contentHeight);
96
+ this.onRowsChanged?.();
97
+ return;
98
+ }
99
+ if (this.cellEditor?.handleClick(event, hit)) return;
100
+ if (this.controller) {
101
+ this.controller.clickNode(hit.row.nodeId, {
102
+ shiftKey: event.shiftKey,
103
+ ctrlKey: event.ctrlKey,
104
+ metaKey: event.metaKey,
105
+ multi: this.canvas.dataset.multi === 'true',
106
+ });
107
+ this.onSelectionChanged?.();
108
+ return;
109
+ }
110
+ this.#selectRow(hit.row.rowIndex, event);
111
+ };
112
+
113
+ #onDoubleClick = (event) => {
114
+ const hit = this.#hitTest(event);
115
+ if (hit?.area === 'row') this.controller?.doubleClickNode(hit.row.nodeId, event);
116
+ };
117
+
118
+ #onMouseDown = (event) => {
119
+ const hit = this.#hitTest(event);
120
+ if (this.cellEditor?.handlePointerDown(event, hit)) {
121
+ event.preventDefault();
122
+ return;
123
+ }
124
+ if (hit?.area !== 'header' || hit.part !== 'resize' || !hit.column) return;
125
+ const rect = this.canvas.getBoundingClientRect();
126
+ this.resizeDrag = {
127
+ columnId: hit.column.id,
128
+ startX: event.clientX - rect.left,
129
+ startWidth: hit.column.width,
130
+ };
131
+ this.canvas.style.cursor = 'col-resize';
132
+ event.preventDefault();
133
+ };
134
+
135
+ #onMouseUp = () => {
136
+ this.resizeDrag = null;
137
+ this.canvas.style.cursor = '';
138
+ };
139
+
140
+ #onKeyDown = (event) => {
141
+ if (this.controller?.handleKey(event)) {
142
+ event.preventDefault();
143
+ this.onSelectionChanged?.();
144
+ return;
145
+ }
146
+ if (!this.rowModel.rows.length) return;
147
+ const currentRow = this.#focusedRowIndex();
148
+ let target = currentRow;
149
+ if (event.key === 'ArrowDown') target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
150
+ else if (event.key === 'ArrowUp') target = Math.max(0, currentRow - 1);
151
+ else if (event.key === 'Home') target = 0;
152
+ else if (event.key === 'End') target = this.rowModel.rows.length - 1;
153
+ else if (event.key === 'ArrowRight') {
154
+ const row = this.rowModel.getRow(currentRow);
155
+ if (row?.hasChildren && !row.expanded) this.#toggleAndRebuild(row.nodeId);
156
+ else if (row?.expanded) target = Math.min(this.rowModel.rows.length - 1, currentRow + 1);
157
+ } else if (event.key === 'ArrowLeft') {
158
+ const row = this.rowModel.getRow(currentRow);
159
+ if (row?.expanded) this.#toggleAndRebuild(row.nodeId);
160
+ else {
161
+ const node = row ? this.rowModel.model.index.getNode(row.nodeId) : null;
162
+ const parentRow = node?.parentId ? this.rowModel.getRowById(node.parentId) : null;
163
+ if (parentRow) target = parentRow.rowIndex;
164
+ }
165
+ } else if (event.key === 'Enter' || event.key === ' ') {
166
+ const row = this.rowModel.getRow(currentRow);
167
+ if (row) this.selection.toggle(row.nodeId);
168
+ this.onSelectionChanged?.();
169
+ event.preventDefault();
170
+ return;
171
+ } else return;
172
+
173
+ const row = this.rowModel.getRow(target);
174
+ if (row) {
175
+ this.selection.select(row.nodeId);
176
+ this.anchorRowIndex = target;
177
+ this.viewport.scrollRowIntoView(target);
178
+ this.onSelectionChanged?.();
179
+ }
180
+ event.preventDefault();
181
+ };
182
+
183
+ #selectRow(rowIndex, event) {
184
+ const row = this.rowModel.getRow(rowIndex);
185
+ if (!row) return;
186
+ if (event.shiftKey && this.anchorRowIndex !== null) {
187
+ this.selection.selected.clear();
188
+ const start = Math.min(this.anchorRowIndex, rowIndex);
189
+ const end = Math.max(this.anchorRowIndex, rowIndex);
190
+ for (let i = start; i <= end; i++) this.selection.selected.add(this.rowModel.rows[i].nodeId);
191
+ this.selection.focused = row.nodeId;
192
+ this.selection.dispatchEvent(new Event('change'));
193
+ } else if (event.ctrlKey || event.metaKey || this.canvas.dataset.multi === 'true') {
194
+ this.selection.toggle(row.nodeId);
195
+ this.anchorRowIndex = rowIndex;
196
+ } else {
197
+ this.selection.select(row.nodeId);
198
+ this.anchorRowIndex = rowIndex;
199
+ }
200
+ this.onSelectionChanged?.();
201
+ }
202
+
203
+ #focusedRowIndex() {
204
+ if (this.selection.focused) {
205
+ const row = this.rowModel.getRowById(this.selection.focused);
206
+ if (row) return row.rowIndex;
207
+ }
208
+ return Math.max(0, Math.floor(this.viewport.scrollY / this.rowModel.rowHeight));
209
+ }
210
+
211
+ #toggleAndRebuild(id) {
212
+ this.expansion.toggle(id);
213
+ this.rowModel.rebuild();
214
+ this.viewport.setContentSize(this.rowModel.contentWidth, this.rowModel.contentHeight);
215
+ this.onRowsChanged?.();
216
+ }
217
+
218
+ #hitTest(event) {
219
+ const rect = this.canvas.getBoundingClientRect();
220
+ const clientX = event.clientX - rect.left;
221
+ const clientY = event.clientY - rect.top;
222
+ if (this.controller) return this.controller.hitTest(clientX, clientY);
223
+ const x = clientX + this.viewport.scrollX;
224
+ const y = clientY - (this.viewport.headerHeight ?? 0) + this.viewport.scrollY;
225
+ if (clientY < (this.viewport.headerHeight ?? 0)) return { area: 'header', part: 'header', x, y: clientY };
226
+ const rowIndex = Math.floor(y / this.rowModel.rowHeight);
227
+ const row = this.rowModel.getRow(rowIndex);
228
+ if (!row) return null;
229
+ const rowX = row.depth * this.rowModel.indentWidth;
230
+ const chevronLeft = rowX + 4;
231
+ const chevronRight = chevronLeft + 18;
232
+ const part = x >= chevronLeft && x <= chevronRight ? 'chevron' : x <= rowX + 42 ? 'icon' : 'body';
233
+ return { area: 'row', row, x, y, part };
234
+ }
235
+ }