virtual-tree-canvas 0.6.0 → 0.7.1

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.
@@ -1,4 +1,5 @@
1
1
  export class TreeViewInputController {
2
+
2
3
  /**
3
4
  * @param {{
4
5
  * controller: import('../tree-view-controller.js').TreeViewController,
@@ -6,8 +7,10 @@ export class TreeViewInputController {
6
7
  * }} options
7
8
  */
8
9
  constructor(options) {
9
- if (!options?.controller) throw new TypeError('TreeViewInputController requires a TreeViewController');
10
- if (!options.controller.canvas) throw new Error('TreeViewInputController requires an initialized TreeViewController canvas');
10
+ if (!options?.controller)
11
+ throw new TypeError('TreeViewInputController requires a TreeViewController');
12
+ if (!options.controller.canvas)
13
+ throw new Error('TreeViewInputController requires an initialized TreeViewController canvas');
11
14
  this.controller = options.controller;
12
15
  this.canvas = options.controller.canvas;
13
16
  this.hoveredId = null;
@@ -15,10 +18,12 @@ export class TreeViewInputController {
15
18
  this.cellEditor = options.cellEditor ?? null;
16
19
  this.canvas.tabIndex = 0;
17
20
 
18
- this.canvas.addEventListener('wheel', this.#onWheel, { passive: false });
21
+ this.canvas.addEventListener('wheel', this.#onWheel, {
22
+ passive: false
23
+ });
19
24
  this.canvas.addEventListener('mousedown', this.#onMouseDown);
20
25
  this.canvas.addEventListener('mousemove', this.#onMouseMove);
21
- window.addEventListener('mouseup', this.#onMouseUp);
26
+ (this.canvas.ownerDocument.defaultView ?? window).addEventListener('mouseup', this.#onMouseUp);
22
27
  this.canvas.addEventListener('mouseleave', this.#onMouseLeave);
23
28
  this.canvas.addEventListener('click', this.#onClick);
24
29
  this.canvas.addEventListener('dblclick', this.#onDoubleClick);
@@ -29,7 +34,10 @@ export class TreeViewInputController {
29
34
  this.canvas.removeEventListener('wheel', this.#onWheel);
30
35
  this.canvas.removeEventListener('mousedown', this.#onMouseDown);
31
36
  this.canvas.removeEventListener('mousemove', this.#onMouseMove);
32
- window.removeEventListener('mouseup', this.#onMouseUp);
37
+ (this.canvas.ownerDocument.defaultView ?? window).removeEventListener(
38
+ 'mouseup',
39
+ this.#onMouseUp
40
+ );
33
41
  this.canvas.removeEventListener('mouseleave', this.#onMouseLeave);
34
42
  this.canvas.removeEventListener('click', this.#onClick);
35
43
  this.canvas.removeEventListener('dblclick', this.#onDoubleClick);
@@ -71,13 +79,18 @@ export class TreeViewInputController {
71
79
  };
72
80
 
73
81
  #onClick = (event) => {
74
- this.canvas.focus();
82
+ if (this.controller.rowReorderInput?.suppressClick) {
83
+ this.controller.rowReorderInput.suppressClick = false;
84
+ event.preventDefault();
85
+ return;
86
+ }
75
87
  const hit = this.#hitTest(event);
76
88
  if (!hit) return;
77
89
  if (['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
78
90
  if (hit.area === 'header') {
79
91
  if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
80
- if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller.sortBy(hit.column.id);
92
+ if (!this.resizeDrag && hit.part === 'label' && hit.column)
93
+ this.controller.sortBy(hit.column.id);
81
94
  return;
82
95
  }
83
96
  if (hit.part === 'chevron' && hit.row.hasChildren) {
@@ -85,11 +98,14 @@ export class TreeViewInputController {
85
98
  return;
86
99
  }
87
100
  if (this.cellEditor?.handleClick(event, hit)) return;
101
+ this.canvas.focus({
102
+ preventScroll: true
103
+ });
88
104
  this.controller.clickNode(hit.row.nodeId, {
89
105
  shiftKey: event.shiftKey,
90
106
  ctrlKey: event.ctrlKey,
91
107
  metaKey: event.metaKey,
92
- multi: this.canvas.dataset.multi === 'true',
108
+ multi: this.canvas.dataset.multi === 'true'
93
109
  });
94
110
  };
95
111
 
@@ -99,6 +115,7 @@ export class TreeViewInputController {
99
115
  };
100
116
 
101
117
  #onMouseDown = (event) => {
118
+ if (this.controller.rowReorderInput?.gesture) return;
102
119
  const hit = this.#hitTest(event);
103
120
  if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) return;
104
121
  this.controller.setActiveHit(hit);
@@ -111,7 +128,7 @@ export class TreeViewInputController {
111
128
  this.resizeDrag = {
112
129
  columnId: hit.column.id,
113
130
  startX: event.clientX - rect.left,
114
- startWidth: hit.column.width,
131
+ startWidth: hit.column.width
115
132
  };
116
133
  this.canvas.style.cursor = 'col-resize';
117
134
  event.preventDefault();
@@ -135,6 +152,7 @@ export class TreeViewInputController {
135
152
  const clientY = event.clientY - rect.top;
136
153
  return this.controller.hitTest(clientX, clientY);
137
154
  }
155
+
138
156
  }
139
157
 
140
158
  function cursorForHit(hit) {
@@ -143,7 +161,14 @@ function cursorForHit(hit) {
143
161
  if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
144
162
  if (hit?.area === 'header' && hit.part === 'filter') return 'text';
145
163
  if (hit?.area !== 'row') return '';
146
- if (hit.part === 'button' || hit.part === 'checkbox' || hit.part === 'arrayAdd' || hit.part === 'arrayRemove' || hit.part === 'chevron') return 'pointer';
164
+ if (
165
+ hit.part === 'button' ||
166
+ hit.part === 'checkbox' ||
167
+ hit.part === 'arrayAdd' ||
168
+ hit.part === 'arrayRemove' ||
169
+ hit.part === 'chevron'
170
+ )
171
+ return 'pointer';
147
172
  if (hit.part === 'editor' || hit.part === 'number') return 'text';
148
173
  if (hit.part === 'range') return 'ew-resize';
149
174
  return '';
@@ -3,14 +3,17 @@ import { numericLayout } from './numeric-layout.js';
3
3
  export class CellEditorManager {
4
4
  constructor({ controller, host = null }) {
5
5
  if (!controller) throw new TypeError('CellEditorManager requires a TreeViewController');
6
- if (!controller.canvas) throw new Error('CellEditorManager requires an initialized TreeViewController canvas');
6
+ if (!controller.canvas)
7
+ throw new Error('CellEditorManager requires an initialized TreeViewController canvas');
7
8
  this.controller = controller;
8
9
  this.canvas = controller.canvas;
10
+ this.view = this.canvas.ownerDocument.defaultView;
9
11
  this.host = host ?? controller.canvas.parentElement ?? document.body;
10
12
  this.editable = true;
11
13
  this.overlay = null;
12
14
  this.rangeDrag = null;
13
15
  this.overlayKind = null;
16
+ this.overlayNode = null;
14
17
  this.stopFilter = controller.on('filterchange', ({ detail }) => {
15
18
  if (this.overlayKind === 'filter' && this.overlay) this.overlay.value = detail.query;
16
19
  });
@@ -26,15 +29,32 @@ export class CellEditorManager {
26
29
  destroy() {
27
30
  this.stopFilter();
28
31
  this.#removeOverlay();
29
- window.removeEventListener('mousemove', this.onMouseMove);
30
- window.removeEventListener('mouseup', this.onMouseUp);
32
+ this.view.removeEventListener('mousemove', this.onMouseMove);
33
+ this.view.removeEventListener('mouseup', this.onMouseUp);
31
34
  }
32
35
 
33
36
  close() {
34
37
  this.#removeOverlay();
35
38
  this.rangeDrag = null;
36
- window.removeEventListener('mousemove', this.onMouseMove);
37
- window.removeEventListener('mouseup', this.onMouseUp);
39
+ this.view.removeEventListener('mousemove', this.onMouseMove);
40
+ this.view.removeEventListener('mouseup', this.onMouseUp);
41
+ }
42
+
43
+ /**
44
+ * Keep an in-progress edit while values refresh; cancel if its editor changes.
45
+ */
46
+ reconcile(nodes) {
47
+ const previous = this.overlayNode ?? this.rangeDrag?.node;
48
+ if (!previous) return;
49
+ const next = nodes.find((node) => node.id === previous.id);
50
+ const signature = (data) =>
51
+ JSON.stringify([
52
+ data.editorType,
53
+ data.readonly,
54
+ data.disabled,
55
+ ...['min', 'max', 'step', 'integer', 'options', 'unit'].map((key) => data.meta?.[key])
56
+ ]);
57
+ if (!next || signature(previous.data) !== signature(next.data)) this.close();
38
58
  }
39
59
 
40
60
  handlePointerDown(event, hit) {
@@ -42,15 +62,21 @@ export class CellEditorManager {
42
62
  const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
43
63
  if (!data || data.readonly || data.disabled) return false;
44
64
  if (data.editorType === 'range' && hit.part === 'range') {
45
- this.rangeDrag = { hit, data };
65
+ this.rangeDrag = {
66
+ hit,
67
+ data,
68
+ node: this.controller.model.nodes[hit.row.nodeIndex]
69
+ };
46
70
  this.#updateRangeFromEvent(event);
47
- window.addEventListener('mousemove', this.onMouseMove);
48
- window.addEventListener('mouseup', this.onMouseUp);
71
+ this.view.addEventListener('mousemove', this.onMouseMove);
72
+ this.view.addEventListener('mouseup', this.onMouseUp);
49
73
  return true;
50
74
  }
51
75
  if (shouldOpenOverlayOnPointerDown(data, hit)) {
52
76
  const node = this.controller.model.nodes[hit.row.nodeIndex];
53
- this.#showOverlay(node, hit, { showPicker: true });
77
+ this.#showOverlay(node, hit, {
78
+ showPicker: true
79
+ });
54
80
  return true;
55
81
  }
56
82
  return false;
@@ -94,7 +120,10 @@ export class CellEditorManager {
94
120
  const hostRect = this.host.getBoundingClientRect();
95
121
  const element = createEditorElement(data);
96
122
  element.className = `vtc-editor vtc-editor-${data.editorType}`;
97
- element.setAttribute('aria-label', [node.label, numericLayout(rect.width, data).unit].filter(Boolean).join(' '));
123
+ element.setAttribute(
124
+ 'aria-label',
125
+ [node.label, numericLayout(rect.width, data).unit].filter(Boolean).join(' ')
126
+ );
98
127
  Object.assign(element.style, {
99
128
  position: 'absolute',
100
129
  left: `${rect.x - hostRect.left}px`,
@@ -114,8 +143,13 @@ export class CellEditorManager {
114
143
  color: '#e5e7eb',
115
144
  boxShadow: '0 0 0 1px rgba(15, 23, 42, 0.8), 0 8px 20px rgba(0, 0, 0, 0.28)',
116
145
  font: editorFont(data),
117
- padding: data.editorType === 'color' ? '0 2px' : data.editorType === 'select' ? '0 22px 0 8px' : '0 8px',
118
- textAlign: data.editorType === 'number' || data.editorType === 'range' ? 'right' : 'left',
146
+ padding:
147
+ data.editorType === 'color'
148
+ ? '0 2px'
149
+ : data.editorType === 'select'
150
+ ? '0 22px 0 8px'
151
+ : '0 8px',
152
+ textAlign: data.editorType === 'number' || data.editorType === 'range' ? 'right' : 'left'
119
153
  });
120
154
  let committed = false;
121
155
  const commit = () => {
@@ -137,7 +171,10 @@ export class CellEditorManager {
137
171
  this.host.append(element);
138
172
  this.overlay = element;
139
173
  this.overlayKind = 'value';
140
- element.focus({ preventScroll: true });
174
+ this.overlayNode = node;
175
+ element.focus({
176
+ preventScroll: true
177
+ });
141
178
  element.select?.();
142
179
  if (options.showPicker && element.showPicker) {
143
180
  requestAnimationFrame(() => {
@@ -153,12 +190,18 @@ export class CellEditorManager {
153
190
  #showHeaderFilterOverlay(hit) {
154
191
  this.#removeOverlay();
155
192
  const headerRect = this.controller.getHeaderClientRect(hit);
156
- const rect = this.#clampRectToHost({
157
- x: headerRect.x + 8,
158
- y: headerRect.y + 5,
159
- width: Math.max(24, Math.min(headerRect.width, this.controller.viewport.contentViewportWidth) - 16),
160
- height: Math.max(20, headerRect.height - 10),
161
- }, 0);
193
+ const rect = this.#clampRectToHost(
194
+ {
195
+ x: headerRect.x + 8,
196
+ y: headerRect.y + 5,
197
+ width: Math.max(
198
+ 24,
199
+ Math.min(headerRect.width, this.controller.viewport.contentViewportWidth) - 16
200
+ ),
201
+ height: Math.max(20, headerRect.height - 10)
202
+ },
203
+ 0
204
+ );
162
205
  const hostRect = this.host.getBoundingClientRect();
163
206
  const element = document.createElement('input');
164
207
  element.type = 'search';
@@ -181,7 +224,7 @@ export class CellEditorManager {
181
224
  background: '#0b1020',
182
225
  color: '#e5e7eb',
183
226
  font: SANS_FONT,
184
- padding: '0 8px',
227
+ padding: '0 8px'
185
228
  });
186
229
  element.addEventListener('input', () => this.controller.setFilter(element.value));
187
230
  element.addEventListener('keydown', (event) => {
@@ -193,7 +236,9 @@ export class CellEditorManager {
193
236
  this.host.append(element);
194
237
  this.overlay = element;
195
238
  this.overlayKind = 'filter';
196
- element.focus({ preventScroll: true });
239
+ element.focus({
240
+ preventScroll: true
241
+ });
197
242
  element.select();
198
243
  }
199
244
 
@@ -219,23 +264,44 @@ export class CellEditorManager {
219
264
 
220
265
  #onMouseUp() {
221
266
  this.rangeDrag = null;
222
- window.removeEventListener('mousemove', this.onMouseMove);
223
- window.removeEventListener('mouseup', this.onMouseUp);
267
+ this.view.removeEventListener('mousemove', this.onMouseMove);
268
+ this.view.removeEventListener('mouseup', this.onMouseUp);
224
269
  }
225
270
 
226
271
  #isEditableHit(hit) {
227
- return hit?.area === 'row' && hit.part !== 'unit' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
272
+ return (
273
+ hit?.area === 'row' &&
274
+ hit.part !== 'unit' &&
275
+ (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane')
276
+ );
228
277
  }
229
278
 
230
279
  #editorContentRect(hit) {
231
280
  const rect = this.controller.getCellClientRect(hit);
232
281
  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 };
282
+ return {
283
+ x: rect.x + 10,
284
+ y: rect.y + 4,
285
+ width: Math.max(24, rect.width - 20),
286
+ height: rect.height - 8
287
+ };
234
288
  }
235
- const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
289
+ const visibleWidth = Math.max(
290
+ 1,
291
+ Math.min(rect.width, this.controller.viewport.contentViewportWidth)
292
+ );
236
293
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
237
- const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
238
- return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: Math.max(24, editorWidth - 20), height: rect.height - 8 };
294
+ const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(
295
+ visibleWidth,
296
+ hit.row,
297
+ data.editorType
298
+ );
299
+ return {
300
+ x: rect.x + editorLeft + 10,
301
+ y: rect.y + 4,
302
+ width: Math.max(24, editorWidth - 20),
303
+ height: rect.height - 8
304
+ };
239
305
  }
240
306
 
241
307
  #overlayRect(hit) {
@@ -243,9 +309,17 @@ export class CellEditorManager {
243
309
  const rect = this.#editorContentRect(hit);
244
310
  const layout = numericLayout(rect.width, data);
245
311
  if (hit.part === 'number' && data.editorType === 'range') {
246
- return { ...rect, x: rect.x + layout.numberLeft, width: layout.valueWidth };
312
+ return {
313
+ ...rect,
314
+ x: rect.x + layout.numberLeft,
315
+ width: layout.valueWidth
316
+ };
247
317
  }
248
- if (layout.unit) return { ...rect, width: layout.contentWidth };
318
+ if (layout.unit)
319
+ return {
320
+ ...rect,
321
+ width: layout.contentWidth
322
+ };
249
323
  if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
250
324
  return rect;
251
325
  }
@@ -253,7 +327,12 @@ export class CellEditorManager {
253
327
  #rangeBarRect(hit) {
254
328
  const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
255
329
  const rect = this.#editorContentRect(hit);
256
- return { ...rect, y: rect.y + rect.height / 2 - 4, width: numericLayout(rect.width, data).barWidth, height: 8 };
330
+ return {
331
+ ...rect,
332
+ y: rect.y + rect.height / 2 - 4,
333
+ width: numericLayout(rect.width, data).barWidth,
334
+ height: 8
335
+ };
257
336
  }
258
337
 
259
338
  #clampRectToHost(rect, inset = 0) {
@@ -266,13 +345,19 @@ export class CellEditorManager {
266
345
  const height = Math.max(18, Math.min(rect.height, maxY - minY));
267
346
  const x = Math.max(minX, Math.min(rect.x, maxX - width));
268
347
  const y = Math.max(minY, Math.min(rect.y, maxY - height));
269
- return { x, y, width, height };
348
+ return {
349
+ x,
350
+ y,
351
+ width,
352
+ height
353
+ };
270
354
  }
271
355
 
272
356
  #removeOverlay() {
273
357
  const overlay = this.overlay;
274
358
  this.overlay = null;
275
359
  this.overlayKind = null;
360
+ this.overlayNode = null;
276
361
  overlay?.remove();
277
362
  }
278
363
 
@@ -280,9 +365,11 @@ export class CellEditorManager {
280
365
  const meta = data.meta ?? {};
281
366
  const min = Number.isFinite(meta.min) ? meta.min : 0;
282
367
  const max = Number.isFinite(meta.max) ? meta.max : 100;
283
- const current = typeof data.value === 'number' && Number.isFinite(data.value) ? data.value : min;
368
+ const current =
369
+ typeof data.value === 'number' && Number.isFinite(data.value) ? data.value : min;
284
370
  this.controller.updateInspectorValue(node.id, current <= min ? max : min, 'range');
285
371
  }
372
+
286
373
  }
287
374
 
288
375
  function createEditorElement(data) {
@@ -312,7 +399,12 @@ function createEditorElement(data) {
312
399
  function shouldOpenOverlayOnPointerDown(data, hit) {
313
400
  if (data.readonly || data.disabled) return false;
314
401
  if (data.editorType === 'range') return hit.part === 'number';
315
- return data.editorType === 'text' || data.editorType === 'number' || data.editorType === 'select' || data.editorType === 'color';
402
+ return (
403
+ data.editorType === 'text' ||
404
+ data.editorType === 'number' ||
405
+ data.editorType === 'select' ||
406
+ data.editorType === 'color'
407
+ );
316
408
  }
317
409
 
318
410
  function ensureOverlayHost(host) {
@@ -338,7 +430,8 @@ function parseEditorValue(element, data) {
338
430
  }
339
431
 
340
432
  const SANS_FONT = '12px Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
341
- const MONO_FONT = '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
433
+ const MONO_FONT =
434
+ '12px "JetBrains Mono", "Cascadia Mono", "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace';
342
435
 
343
436
  function editorFont(data) {
344
437
  return data.editorType === 'number' || data.editorType === 'range' ? MONO_FONT : SANS_FONT;
@@ -0,0 +1,20 @@
1
+ export function inspectorPaneLayout(
2
+ width,
3
+ depth = 0,
4
+ indentWidth = 18,
5
+ editorType = '',
6
+ labelEnd = 0
7
+ ) {
8
+ const safeWidth = Math.max(1, width);
9
+ const rightPadding = 14;
10
+ const minEditor = Math.min(170, Math.max(96, safeWidth * 0.45));
11
+ const minLabelEnd = Math.max(88, depth * indentWidth + 104);
12
+ const preferredLeft = Math.max(minLabelEnd, labelEnd, safeWidth * 0.32);
13
+ const maxLeft = Math.max(64, safeWidth - rightPadding - minEditor);
14
+ const editorLeft = Math.max(64, Math.min(preferredLeft, maxLeft));
15
+ const editorWidth = Math.max(56, safeWidth - rightPadding - editorLeft);
16
+ return {
17
+ editorLeft,
18
+ editorWidth
19
+ };
20
+ }
@@ -0,0 +1,13 @@
1
+ export function drawCheckbox(ctx, x, y, checked, theme) {
2
+ ctx.beginPath();
3
+ ctx.roundRect(x, y, 16, 16, 3);
4
+ ctx.fillStyle = theme.colors.progressTrack;
5
+ ctx.fill();
6
+ ctx.strokeStyle = checked ? theme.colors.progressFill : theme.colors.textMuted;
7
+ ctx.stroke();
8
+ if (!checked) return;
9
+ ctx.fillStyle = theme.colors.progressFill;
10
+ ctx.beginPath();
11
+ ctx.roundRect(x + 4, y + 4, 8, 8, 1.5);
12
+ ctx.fill();
13
+ }