virtual-tree-canvas 0.7.0 → 0.7.2

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,6 +1,8 @@
1
1
  let nextStatusId = 0;
2
2
 
3
- /** Pointer interaction for the optional row-order column; persistence belongs to the host. */
3
+ /**
4
+ * Pointer interaction for the optional row-order column; persistence belongs to the host.
5
+ */
4
6
  export class RowReorderInput {
5
7
  constructor(controller) {
6
8
  this.controller = controller;
@@ -16,61 +18,97 @@ export class RowReorderInput {
16
18
  this.view.addEventListener('pointerup', this.onUp);
17
19
  this.view.addEventListener('pointercancel', this.onCancel);
18
20
  this.view.addEventListener('blur', this.cancel);
19
- this.stops = ['filterchange', 'sortchange', 'rowreorderchange'].map(type => controller.on(type, () => {
20
- this.cancel();
21
- this.syncMode();
22
- }));
23
- this.stops.push(controller.on('datachange', () => {
24
- if (this.gesture && !controller.model.index.getNode(this.gesture.id)) this.cancel();
25
- }));
26
- this.stops.push(controller.on('rowreorder', event => {
27
- if (!this.status) return;
28
- const { nodeId, toIndex, siblingOrder } = event.detail;
29
- const label = controller.model.index.getNode(nodeId)?.label ?? nodeId;
30
- this.status.textContent = `${label}: position ${toIndex + 1} of ${siblingOrder.length}`;
31
- }));
21
+ this.stops = ['filterchange', 'sortchange', 'rowreorderchange'].map((type) =>
22
+ controller.on(type, () => {
23
+ this.cancel();
24
+ this.syncMode();
25
+ })
26
+ );
27
+ this.stops.push(
28
+ controller.on('datachange', () => {
29
+ if (this.gesture && !controller.model.index.getNode(this.gesture.id)) this.cancel();
30
+ })
31
+ );
32
+ this.stops.push(
33
+ controller.on('rowreorder', (event) => {
34
+ if (!this.status) return;
35
+ const { nodeId, toIndex, siblingOrder } = event.detail;
36
+ const label = controller.model.index.getNode(nodeId)?.label ?? nodeId;
37
+ this.status.textContent = `${label}: position ${toIndex + 1} of ${siblingOrder.length}`;
38
+ })
39
+ );
32
40
  const doc = this.canvas.ownerDocument;
33
41
  if (doc?.createElement && this.canvas.parentElement) {
34
42
  this.status = doc.createElement('span');
35
43
  this.status.id = `vtc-row-order-status-${nextStatusId++}`;
36
44
  this.status.setAttribute('aria-live', 'polite');
37
- Object.assign(this.status.style, { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' });
45
+ Object.assign(this.status.style, {
46
+ position: 'absolute',
47
+ width: '1px',
48
+ height: '1px',
49
+ overflow: 'hidden',
50
+ clipPath: 'inset(50%)'
51
+ });
38
52
  this.canvas.parentElement.appendChild(this.status);
39
53
  }
40
54
  this.syncMode();
41
55
  }
42
56
 
43
57
  syncMode() {
44
- this.canvas.style.touchAction = (this.controller.canReorderRows() || this.controller.rowDrag) ? 'none' : this.previousTouchAction;
58
+ this.canvas.style.touchAction =
59
+ this.controller.canReorderRows() || this.controller.rowDrag
60
+ ? 'none'
61
+ : this.previousTouchAction;
45
62
  }
46
63
 
47
- onDown = event => {
64
+ onDown = (event) => {
48
65
  if (event.button !== 0 || event.isPrimary === false || this.gesture) return;
49
66
  this.suppressClick = false;
50
67
  const point = this.point(event);
51
68
  const hit = this.controller.hitTest(point.x, point.y);
52
69
  if (!hit?.row) return;
53
70
  const node = this.controller.model.index.getNode(hit.row.nodeId);
54
- const payload = this.controller.rowDrag?.(node, this.controller.model.dynamicState.get(node.id) ?? {}) ?? null;
71
+ const payload =
72
+ this.controller.rowDrag?.(node, this.controller.model.dynamicState.get(node.id) ?? {}) ??
73
+ null;
55
74
  const orderPart = ['rowDrag', 'rowUp', 'rowDown'].includes(hit.part);
56
75
  if (orderPart && node.reorderable === false) return;
57
- if (orderPart ? !this.controller.canReorderRows() && !(payload != null && hit.part === 'rowDrag') : payload == null || !['label', 'row', 'icon', 'cell'].includes(hit.part)) return;
58
- this.canvas.focus({ preventScroll: true });
76
+ if (
77
+ orderPart
78
+ ? !this.controller.canReorderRows() && !(payload != null && hit.part === 'rowDrag')
79
+ : payload == null || !['label', 'row', 'icon', 'cell'].includes(hit.part)
80
+ )
81
+ return;
82
+ this.canvas.focus({
83
+ preventScroll: true
84
+ });
59
85
  this.controller.cellEditor?.close?.();
60
86
  this.controller.focusedId = hit.row.nodeId;
61
- this.gesture = { id: hit.row.nodeId, part: orderPart ? hit.part : 'rowDrag', payload, pointerId: event.pointerId,
62
- startX: point.x, startY: point.y, point, dragging: false };
87
+ this.gesture = {
88
+ id: hit.row.nodeId,
89
+ part: orderPart ? hit.part : 'rowDrag',
90
+ payload,
91
+ pointerId: event.pointerId,
92
+ startX: point.x,
93
+ startY: point.y,
94
+ point,
95
+ dragging: false
96
+ };
63
97
  this.canvas.setPointerCapture?.(event.pointerId);
64
98
  event.stopPropagation();
65
99
  if (orderPart) event.preventDefault();
66
100
  };
67
101
 
68
- onMove = event => {
102
+ onMove = (event) => {
69
103
  const gesture = this.gesture;
70
104
  if (!gesture || event.pointerId !== gesture.pointerId) return;
71
105
  gesture.point = this.point(event);
72
106
  if (gesture.part !== 'rowDrag') return;
73
- if (!gesture.dragging && Math.hypot(gesture.point.x - gesture.startX, gesture.point.y - gesture.startY) < 5) return;
107
+ if (
108
+ !gesture.dragging &&
109
+ Math.hypot(gesture.point.x - gesture.startX, gesture.point.y - gesture.startY) < 5
110
+ )
111
+ return;
74
112
  if (!gesture.dragging && gesture.payload != null) this.emitDrag('rowdragstart', event);
75
113
  gesture.dragging = true;
76
114
  if (gesture.payload != null) this.emitDrag('rowdragmove', event);
@@ -80,24 +118,37 @@ export class RowReorderInput {
80
118
  event.preventDefault();
81
119
  };
82
120
 
83
- onUp = event => {
121
+ onUp = (event) => {
84
122
  const gesture = this.gesture;
85
123
  if (!gesture || event.pointerId !== gesture.pointerId) return;
86
124
  gesture.point = this.point(event);
87
- const target = gesture.dragging ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null;
125
+ const target = gesture.dragging
126
+ ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y)
127
+ : null;
88
128
  const hit = this.controller.hitTest(gesture.point.x, gesture.point.y);
89
129
  if (gesture.dragging && gesture.payload != null) this.emitDrag('rowdragend', event);
90
130
  this.suppressClick = gesture.dragging;
91
131
  this.cancel(false);
92
- if (target) this.controller.moveRow(gesture.id, target.index, { source: 'pointer' });
132
+ if (target)
133
+ this.controller.moveRow(gesture.id, target.index, {
134
+ source: 'pointer'
135
+ });
93
136
  else if (!gesture.dragging && hit?.row?.nodeId === gesture.id && hit.part === gesture.part) {
94
- if (gesture.part === 'rowUp') this.controller.moveRowBy(gesture.id, -1, { source: 'button' });
95
- if (gesture.part === 'rowDown') this.controller.moveRowBy(gesture.id, 1, { source: 'button' });
137
+ if (gesture.part === 'rowUp')
138
+ this.controller.moveRowBy(gesture.id, -1, {
139
+ source: 'button'
140
+ });
141
+ if (gesture.part === 'rowDown')
142
+ this.controller.moveRowBy(gesture.id, 1, {
143
+ source: 'button'
144
+ });
96
145
  }
97
146
  };
98
147
 
99
- onCancel = event => { if (this.gesture?.pointerId === event.pointerId) this.cancel(); };
100
- onKey = event => {
148
+ onCancel = (event) => {
149
+ if (this.gesture?.pointerId === event.pointerId) this.cancel();
150
+ };
151
+ onKey = (event) => {
101
152
  if (event.key === 'Escape' && this.gesture) {
102
153
  this.cancel();
103
154
  event.preventDefault();
@@ -106,24 +157,38 @@ export class RowReorderInput {
106
157
 
107
158
  point(event) {
108
159
  const rect = this.canvas.getBoundingClientRect();
109
- return { x: event.clientX - rect.left, y: event.clientY - rect.top };
160
+ return {
161
+ x: event.clientX - rect.left,
162
+ y: event.clientY - rect.top
163
+ };
110
164
  }
111
165
 
112
166
  updateTarget() {
113
167
  const gesture = this.gesture;
114
- this.controller.setRowDrop(gesture ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null);
168
+ this.controller.setRowDrop(
169
+ gesture
170
+ ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y)
171
+ : null
172
+ );
115
173
  }
116
174
 
117
175
  scheduleScroll() {
118
176
  if (this.frame !== null || !this.gesture?.dragging) return;
119
177
  const viewport = this.controller.viewport;
120
- if (this.gesture.point.x < 0 || this.gesture.point.x > viewport.viewportWidth || this.gesture.point.y < 0 || this.gesture.point.y > viewport.viewportHeight) return;
178
+ if (
179
+ this.gesture.point.x < 0 ||
180
+ this.gesture.point.x > viewport.viewportWidth ||
181
+ this.gesture.point.y < 0 ||
182
+ this.gesture.point.y > viewport.viewportHeight
183
+ )
184
+ return;
121
185
  const y = this.gesture.point.y - viewport.renderInsetY;
122
186
  const top = viewport.headerHeight;
123
187
  const bottom = top + viewport.rowViewportHeight;
124
188
  const delta = y < top + 24 ? -10 : y > bottom - 24 ? 10 : 0;
125
189
  if (!delta) return;
126
- const schedule = this.view.requestAnimationFrame?.bind(this.view) ?? (callback => setTimeout(callback, 16));
190
+ const schedule =
191
+ this.view.requestAnimationFrame?.bind(this.view) ?? ((callback) => setTimeout(callback, 16));
127
192
  this.frame = schedule(() => {
128
193
  this.frame = null;
129
194
  if (!this.gesture?.dragging) return;
@@ -136,8 +201,13 @@ export class RowReorderInput {
136
201
 
137
202
  emitDrag(type, originalEvent) {
138
203
  const gesture = this.gesture;
139
- if (gesture) this.controller.events.emit(type, { nodeId: gesture.id, payload: gesture.payload,
140
- label: this.controller.model.index.getNode(gesture.id)?.label ?? gesture.id, originalEvent });
204
+ if (gesture)
205
+ this.controller.events.emit(type, {
206
+ nodeId: gesture.id,
207
+ payload: gesture.payload,
208
+ label: this.controller.model.index.getNode(gesture.id)?.label ?? gesture.id,
209
+ originalEvent
210
+ });
141
211
  }
142
212
 
143
213
  cancel = (notify = true) => {
@@ -145,9 +215,11 @@ export class RowReorderInput {
145
215
  if (gesture?.dragging) this.suppressClick = true;
146
216
  if (notify && gesture?.dragging && gesture.payload != null) this.emitDrag('rowdragcancel');
147
217
  this.gesture = null;
148
- if (this.frame !== null) (this.view.cancelAnimationFrame?.bind(this.view) ?? clearTimeout)(this.frame);
218
+ if (this.frame !== null)
219
+ (this.view.cancelAnimationFrame?.bind(this.view) ?? clearTimeout)(this.frame);
149
220
  this.frame = null;
150
- if (gesture && this.canvas.hasPointerCapture?.(gesture.pointerId)) this.canvas.releasePointerCapture(gesture.pointerId);
221
+ if (gesture && this.canvas.hasPointerCapture?.(gesture.pointerId))
222
+ this.canvas.releasePointerCapture(gesture.pointerId);
151
223
  this.canvas.style.cursor = '';
152
224
  this.controller.setRowDrop(null);
153
225
  };
@@ -165,4 +237,5 @@ export class RowReorderInput {
165
237
  for (const stop of this.stops) stop();
166
238
  this.status?.remove();
167
239
  }
240
+
168
241
  }
@@ -4,6 +4,8 @@ export class TreeTooltip {
4
4
  this.controller = controller;
5
5
  this.canvas = controller.canvas;
6
6
  this.host = host;
7
+ this.pointer = null;
8
+ this.targetKey = null;
7
9
  if (!this.canvas || !host?.ownerDocument) throw new Error('TreeTooltip requires a mounted canvas and host');
8
10
  this.element = host.ownerDocument.createElement('div');
9
11
  this.element.className = 'virtual-tree-canvas-tooltip';
@@ -17,13 +19,28 @@ export class TreeTooltip {
17
19
  });
18
20
  host.appendChild(this.element);
19
21
  this.canvas.addEventListener('mousemove', this.onMove);
20
- this.canvas.addEventListener('mouseleave', this.hide);
22
+ this.canvas.addEventListener('mouseleave', this.onLeave);
21
23
  this.canvas.addEventListener('wheel', this.hide);
22
24
  this.canvas.addEventListener('pointerdown', this.hide);
23
- this.stops = ['viewportchange', 'filterchange', 'datachange', 'rowdrag'].map(type => controller.on(type, this.hide));
25
+ this.stops = [
26
+ ...['viewportchange', 'filterchange', 'rowdrag'].map(type => controller.on(type, this.hide)),
27
+ controller.on('datachange', this.refresh)
28
+ ];
24
29
  }
25
30
 
26
31
  onMove = event => {
32
+ this.pointer = { clientX: event.clientX, clientY: event.clientY };
33
+ this.#update(true, false);
34
+ };
35
+
36
+ /** Refresh a visible tooltip without requiring another mousemove event. */
37
+ refresh = () => {
38
+ if (this.element.style.display === 'none' || !this.pointer) return;
39
+ this.#update(false, true);
40
+ };
41
+
42
+ #update(reposition, preserveTarget) {
43
+ const event = this.pointer;
27
44
  const rect = this.canvas.getBoundingClientRect();
28
45
  const hit = this.controller.hitTest(event.clientX - rect.left, event.clientY - rect.top);
29
46
  let text;
@@ -33,23 +50,40 @@ export class TreeTooltip {
33
50
  : 'Clear sorting and filtering to reorder rows.';
34
51
  } else text = this.controller.getTooltipForHit(hit)?.text;
35
52
  if (!text || this.controller.rowReorderInput?.gesture) return this.hide();
53
+ const targetKey = tooltipTargetKey(hit);
54
+ if (preserveTarget && targetKey !== this.targetKey) return this.hide();
55
+ this.targetKey = targetKey;
56
+ if (this.element.textContent !== text) this.element.textContent = text;
57
+ if (!reposition) return;
36
58
  const hostRect = this.host.getBoundingClientRect();
37
- this.element.textContent = text;
38
59
  this.element.style.display = 'block';
39
60
  const x = event.clientX - hostRect.left + 12;
40
61
  const y = event.clientY - hostRect.top + 14;
41
62
  this.element.style.left = `${Math.max(8, Math.min(x, hostRect.width - this.element.offsetWidth - 8))}px`;
42
63
  this.element.style.top = `${Math.max(8, Math.min(y, hostRect.height - this.element.offsetHeight - 8))}px`;
64
+ }
65
+
66
+ hide = () => {
67
+ this.element.style.display = 'none';
68
+ this.targetKey = null;
43
69
  };
44
70
 
45
- hide = () => { this.element.style.display = 'none'; };
71
+ onLeave = () => {
72
+ this.pointer = null;
73
+ this.hide();
74
+ };
46
75
 
47
76
  destroy() {
48
77
  this.canvas.removeEventListener('mousemove', this.onMove);
49
- this.canvas.removeEventListener('mouseleave', this.hide);
78
+ this.canvas.removeEventListener('mouseleave', this.onLeave);
50
79
  this.canvas.removeEventListener('wheel', this.hide);
51
80
  this.canvas.removeEventListener('pointerdown', this.hide);
52
81
  for (const stop of this.stops) stop();
53
82
  this.element.remove();
54
83
  }
55
84
  }
85
+
86
+ function tooltipTargetKey(hit) {
87
+ if (!hit) return '';
88
+ return `${hit.area ?? ''}\0${hit.row?.nodeId ?? ''}\0${hit.column?.id ?? ''}\0${hit.part ?? ''}`;
89
+ }
@@ -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,7 +18,9 @@ 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
26
  (this.canvas.ownerDocument.defaultView ?? window).addEventListener('mouseup', this.#onMouseUp);
@@ -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
- (this.canvas.ownerDocument.defaultView ?? 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
- if (this.controller.rowReorderInput?.suppressClick) { this.controller.rowReorderInput.suppressClick = false; event.preventDefault(); return; }
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,12 +98,14 @@ export class TreeViewInputController {
85
98
  return;
86
99
  }
87
100
  if (this.cellEditor?.handleClick(event, hit)) return;
88
- this.canvas.focus({ preventScroll: true });
101
+ this.canvas.focus({
102
+ preventScroll: true
103
+ });
89
104
  this.controller.clickNode(hit.row.nodeId, {
90
105
  shiftKey: event.shiftKey,
91
106
  ctrlKey: event.ctrlKey,
92
107
  metaKey: event.metaKey,
93
- multi: this.canvas.dataset.multi === 'true',
108
+ multi: this.canvas.dataset.multi === 'true'
94
109
  });
95
110
  };
96
111
 
@@ -113,7 +128,7 @@ export class TreeViewInputController {
113
128
  this.resizeDrag = {
114
129
  columnId: hit.column.id,
115
130
  startX: event.clientX - rect.left,
116
- startWidth: hit.column.width,
131
+ startWidth: hit.column.width
117
132
  };
118
133
  this.canvas.style.cursor = 'col-resize';
119
134
  event.preventDefault();
@@ -137,6 +152,7 @@ export class TreeViewInputController {
137
152
  const clientY = event.clientY - rect.top;
138
153
  return this.controller.hitTest(clientX, clientY);
139
154
  }
155
+
140
156
  }
141
157
 
142
158
  function cursorForHit(hit) {
@@ -145,7 +161,14 @@ function cursorForHit(hit) {
145
161
  if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
146
162
  if (hit?.area === 'header' && hit.part === 'filter') return 'text';
147
163
  if (hit?.area !== 'row') return '';
148
- 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';
149
172
  if (hit.part === 'editor' || hit.part === 'number') return 'text';
150
173
  if (hit.part === 'range') return 'ew-resize';
151
174
  return '';