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.
@@ -0,0 +1,248 @@
1
+ import { drawCheckbox } from '../renderers/checkbox.js';
2
+
3
+ /** Accessible buttons for visible rows. Actions and their meaning belong to the host. */
4
+ export class RowActions {
5
+ constructor(controller) {
6
+ this.controller = controller;
7
+ this.buttons = new Map();
8
+ this.buttonsByNodeId = new Map();
9
+ this.actionsById = new Map();
10
+ this.visibleRowsById = new Map();
11
+ this.preparedIcons = new Set();
12
+ this.fullUpdates = 0;
13
+ this.incrementalUpdates = 0;
14
+ this.themeToken = 0;
15
+ this.theme = null;
16
+ this.element = controller.canvas.ownerDocument.createElement('div');
17
+ Object.assign(this.element.style, {
18
+ position: 'absolute', overflow: 'hidden', pointerEvents: 'none', zIndex: '2'
19
+ });
20
+ const style = controller.canvas.ownerDocument.createElement('style');
21
+ style.textContent = `
22
+ .vtc-row-action { appearance:none; display:flex; align-items:center; justify-content:center;
23
+ background:transparent; color:inherit; outline:none; }
24
+ .vtc-row-action:hover:not(:disabled) canvas { filter:brightness(1.45) drop-shadow(0 0 2px var(--vtc-action-focus)); }
25
+ .vtc-row-action:focus-visible { outline:1px solid var(--vtc-action-focus); outline-offset:-2px; }
26
+ `;
27
+ this.element.append(style);
28
+ controller.canvas.parentElement?.append(this.element);
29
+ this.element.addEventListener('pointerdown', (event) => event.stopPropagation());
30
+ }
31
+
32
+ /** @param {object} scene @param {{full?: boolean, dirtyNodeIds?: Set<string>}} invalidation */
33
+ render(scene, invalidation = { full: true }) {
34
+ if (!invalidation.full && invalidation.dirtyNodeIds?.size) {
35
+ this.incrementalUpdates++;
36
+ for (const id of invalidation.dirtyNodeIds) this.#syncDynamicRow(id, scene);
37
+ return;
38
+ }
39
+ this.fullUpdates++;
40
+ this.#syncAll(scene);
41
+ }
42
+
43
+ #syncAll(scene) {
44
+ const { viewport, theme, rows, visibleRange, stickyRows } = scene;
45
+ if (this.theme !== theme) {
46
+ this.theme = theme;
47
+ this.themeToken++;
48
+ }
49
+ this.actionsById.clear();
50
+ for (const action of this.controller.rowActions) this.actionsById.set(action.id, action);
51
+ const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
52
+ this.#prepareIcons(theme, ratio);
53
+ const column = scene.columns.find((column) => column.id === '__vtc_actions');
54
+ const width = column?.width ?? 0;
55
+ setStyle(this.element, '--vtc-action-focus', theme.colors.focus);
56
+ setStyle(this.element, 'background', 'transparent');
57
+ setStyle(this.element, 'left', `${viewport.renderInsetX + Math.max(0, viewport.contentViewportWidth - width)}px`);
58
+ setStyle(this.element, 'top', `${viewport.renderInsetY + viewport.headerHeight}px`);
59
+ setStyle(this.element, 'width', `${width}px`);
60
+ setStyle(this.element, 'height', `${viewport.rowViewportHeight}px`);
61
+
62
+ const used = new Set();
63
+ this.visibleRowsById.clear();
64
+ if (column) {
65
+ const stickyBottom = stickyRows.reduce(
66
+ (end, row) => Math.max(end, row.stickyY + row.height), 0
67
+ );
68
+ for (let i = visibleRange.first; i <= visibleRange.last; i++) {
69
+ const row = rows[i];
70
+ if (!row || row.y + row.height - viewport.scrollY <= stickyBottom) continue;
71
+ this.#rememberVisibleRow(row, row.y - viewport.scrollY, false, stickyBottom, viewport);
72
+ }
73
+ for (const row of stickyRows) {
74
+ this.#rememberVisibleRow(row, row.stickyY, true, stickyBottom, viewport);
75
+ }
76
+ for (const info of this.visibleRowsById.values()) this.#syncRow(info, scene, used, ratio);
77
+ }
78
+ for (const [key, button] of this.buttons) {
79
+ if (!used.has(button)) this.#removeButton(key, button, true);
80
+ }
81
+ }
82
+
83
+ #rememberVisibleRow(row, y, sticky, stickyBottom, viewport) {
84
+ if (y + row.height <= 0 || y >= viewport.rowViewportHeight) return;
85
+ this.visibleRowsById.set(row.nodeId, { row, y, sticky, stickyBottom });
86
+ }
87
+
88
+ #syncDynamicRow(id, scene) {
89
+ const info = this.visibleRowsById.get(id);
90
+ if (!info) return;
91
+ const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
92
+ this.#syncRow(info, scene, null, ratio);
93
+ }
94
+
95
+ #syncRow(info, scene, used, ratio) {
96
+ const { row, y, sticky, stickyBottom } = info;
97
+ const node = this.controller.model.index.getNode(row.nodeId);
98
+ if (!node) return;
99
+ const state = this.controller.model.dynamicState.get(node.id) ?? {};
100
+ this.controller.rowActions.forEach((action, index) => {
101
+ const visible = resolve(action.visible, node, state) !== false;
102
+ const key = actionKey(node.id, action.id);
103
+ let button = this.buttons.get(key);
104
+ if (!visible) {
105
+ if (button) this.#removeButton(key, button, false);
106
+ return;
107
+ }
108
+ if (button?._vtcKind !== action.kind) {
109
+ this.#removeButton(key, button, false);
110
+ button = null;
111
+ }
112
+ if (!button) button = this.#createButton(key, node, action);
113
+ used?.add(button);
114
+ this.#syncButton(button, node, state, action, index, row, y, sticky, stickyBottom, scene, ratio);
115
+ });
116
+ }
117
+
118
+ #createButton(key, node, action) {
119
+ const button = this.element.ownerDocument.createElement('button');
120
+ button.type = 'button';
121
+ button._vtcKind = action.kind;
122
+ if (action.kind === 'checkbox') button.setAttribute('role', 'checkbox');
123
+ button.className = 'vtc-row-action';
124
+ button.dataset.nodeId = node.id;
125
+ button.dataset.action = action.id;
126
+ Object.assign(button.style, {
127
+ position: 'absolute', pointerEvents: 'auto', width: '26px', padding: '3px',
128
+ border: '0', borderRadius: '3px', cursor: 'pointer'
129
+ });
130
+ const icon = this.element.ownerDocument.createElement('canvas');
131
+ Object.assign(icon.style, { width: '16px', height: '16px', display: 'block' });
132
+ button.append(icon);
133
+ button.onclick = (event) => this.#activate(button, event);
134
+ this.buttons.set(key, button);
135
+ let nodeButtons = this.buttonsByNodeId.get(node.id);
136
+ if (!nodeButtons) this.buttonsByNodeId.set(node.id, (nodeButtons = new Map()));
137
+ nodeButtons.set(action.id, button);
138
+ this.element.append(button);
139
+ return button;
140
+ }
141
+
142
+ #syncButton(button, node, state, action, index, row, y, sticky, stickyBottom, scene, ratio) {
143
+ setProperty(button, 'title', action.label);
144
+ setAttribute(button, 'aria-label', `${action.label}: ${node.label ?? node.id}`);
145
+ const pressed = action.pressed === undefined ? null : Boolean(resolve(action.pressed, node, state));
146
+ setAttribute(button, 'aria-pressed', pressed === null ? null : String(pressed));
147
+ const disabled = Boolean(resolve(action.disabled, node, state));
148
+ setProperty(button, 'disabled', disabled);
149
+ setStyle(button, 'left', `${index * 28 + 1}px`);
150
+ setStyle(button, 'top', `${y + 1}px`);
151
+ setStyle(button, 'height', `${row.height - 2}px`);
152
+ setStyle(button, 'clipPath', sticky ? 'none' : `inset(${Math.max(0, stickyBottom - y - 1)}px 0 0)`);
153
+ setStyle(button, 'opacity', disabled ? '0.3' : '1');
154
+
155
+ const canvas = button.firstChild;
156
+ const size = action.kind === 'checkbox' ? 18 : 16;
157
+ const pixels = Math.round(size * ratio);
158
+ setStyle(canvas, 'width', `${size}px`);
159
+ setStyle(canvas, 'height', `${size}px`);
160
+ const checked = action.kind === 'checkbox' && Boolean(resolve(action.checked, node, state));
161
+ if (action.kind === 'checkbox') setAttribute(button, 'aria-checked', String(checked));
162
+ const icon = action.kind === 'checkbox' ? '' : resolve(action.icon, node, state) ?? 'star';
163
+ const color = pressed ? scene.theme.colors.text : scene.theme.colors.textMuted;
164
+ const drawKey = `${this.themeToken}\0${action.kind ?? ''}\0${icon}\0${color}\0${checked}\0${pixels}`;
165
+ if (canvas._vtcDrawKey === drawKey) return;
166
+ canvas._vtcDrawKey = drawKey;
167
+ if (canvas.width !== pixels) canvas.width = pixels;
168
+ if (canvas.height !== pixels) canvas.height = pixels;
169
+ const ctx = canvas.getContext('2d');
170
+ ctx.setTransform(pixels / size, 0, 0, pixels / size, 0, 0);
171
+ ctx.clearRect(0, 0, size, size);
172
+ if (action.kind === 'checkbox') drawCheckbox(ctx, 1, 1, checked, scene.theme);
173
+ else this.controller.iconRegistry.draw(ctx, icon, 0, 0, 16, color);
174
+ }
175
+
176
+ #activate(button, event) {
177
+ event.stopPropagation();
178
+ this.controller.flushDynamicState();
179
+ const current = this.controller.model.index.getNode(button.dataset.nodeId);
180
+ const spec = this.actionsById.get(button.dataset.action);
181
+ const state = this.controller.model.dynamicState.get(current?.id) ?? {};
182
+ if (!current || !spec || resolve(spec.disabled, current, state)) return;
183
+ this.controller.events.emit('rowaction', {
184
+ actionId: spec.id, nodeId: current.id, node: current,
185
+ ...(spec.kind === 'checkbox'
186
+ ? { checked: !Boolean(resolve(spec.checked, current, state)) }
187
+ : {}),
188
+ originalEvent: event
189
+ });
190
+ }
191
+
192
+ #prepareIcons(theme, ratio) {
193
+ for (const action of this.controller.rowActions) {
194
+ const icons = action.preloadIcons ?? (typeof action.icon === 'string' ? [action.icon] : []);
195
+ for (const icon of icons) for (const color of [theme.colors.text, theme.colors.textMuted]) {
196
+ const key = `${icon}\0${color}\0${ratio}`;
197
+ if (this.preparedIcons.has(key)) continue;
198
+ this.preparedIcons.add(key);
199
+ void this.controller.iconRegistry.prepare({ icons: [icon], size: 16, color, pixelRatio: ratio });
200
+ }
201
+ }
202
+ }
203
+
204
+ #removeButton(key, button, preserveFocus) {
205
+ if (!button) return;
206
+ const focused = this.element.getRootNode().activeElement === button;
207
+ button.remove();
208
+ this.buttons.delete(key);
209
+ const nodeButtons = this.buttonsByNodeId.get(button.dataset.nodeId);
210
+ nodeButtons?.delete(button.dataset.action);
211
+ if (!nodeButtons?.size) this.buttonsByNodeId.delete(button.dataset.nodeId);
212
+ if (focused && preserveFocus) {
213
+ (this.buttons.values().next().value ?? this.controller.canvas).focus({ preventScroll: true });
214
+ } else if (focused) this.controller.canvas.focus({ preventScroll: true });
215
+ }
216
+
217
+ destroy() {
218
+ this.element.remove();
219
+ this.buttons.clear();
220
+ this.buttonsByNodeId.clear();
221
+ this.actionsById.clear();
222
+ this.visibleRowsById.clear();
223
+ }
224
+ }
225
+
226
+ function resolve(value, node, state) {
227
+ return typeof value === 'function' ? value(node, state) : value;
228
+ }
229
+
230
+ function actionKey(nodeId, actionId) {
231
+ return `${nodeId.length}:${nodeId}${actionId}`;
232
+ }
233
+
234
+ function setProperty(target, name, value) {
235
+ if (!Object.is(target[name], value)) target[name] = value;
236
+ }
237
+
238
+ function setAttribute(target, name, value) {
239
+ if (value === null) {
240
+ if (target.hasAttribute(name)) target.removeAttribute(name);
241
+ } else if (target.getAttribute(name) !== value) target.setAttribute(name, value);
242
+ }
243
+
244
+ function setStyle(target, name, value) {
245
+ if (name.startsWith('--')) {
246
+ if (target.style.getPropertyValue(name) !== value) target.style.setProperty(name, value);
247
+ } else if (target.style[name] !== value) target.style[name] = value;
248
+ }
@@ -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;
@@ -11,78 +13,142 @@ export class RowReorderInput {
11
13
  this.previousTouchAction = this.canvas.style.touchAction;
12
14
  this.canvas.addEventListener('pointerdown', this.onDown);
13
15
  this.canvas.addEventListener('keydown', this.onKey);
16
+ this.canvas.addEventListener('lostpointercapture', this.onCancel);
14
17
  this.view.addEventListener('pointermove', this.onMove);
15
18
  this.view.addEventListener('pointerup', this.onUp);
16
19
  this.view.addEventListener('pointercancel', this.onCancel);
17
20
  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
- }));
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
+ );
28
40
  const doc = this.canvas.ownerDocument;
29
41
  if (doc?.createElement && this.canvas.parentElement) {
30
42
  this.status = doc.createElement('span');
31
43
  this.status.id = `vtc-row-order-status-${nextStatusId++}`;
32
44
  this.status.setAttribute('aria-live', 'polite');
33
- 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
+ });
34
52
  this.canvas.parentElement.appendChild(this.status);
35
53
  }
36
54
  this.syncMode();
37
55
  }
38
56
 
39
57
  syncMode() {
40
- this.canvas.style.touchAction = this.controller.canReorderRows() ? 'none' : this.previousTouchAction;
58
+ this.canvas.style.touchAction =
59
+ this.controller.canReorderRows() || this.controller.rowDrag
60
+ ? 'none'
61
+ : this.previousTouchAction;
41
62
  }
42
63
 
43
- onDown = event => {
44
- if (event.button !== 0 || !this.controller.canReorderRows() || this.gesture) return;
64
+ onDown = (event) => {
65
+ if (event.button !== 0 || event.isPrimary === false || this.gesture) return;
66
+ this.suppressClick = false;
45
67
  const point = this.point(event);
46
68
  const hit = this.controller.hitTest(point.x, point.y);
47
- if (!hit?.row || !['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
48
- this.canvas.focus();
69
+ if (!hit?.row) return;
70
+ const node = this.controller.model.index.getNode(hit.row.nodeId);
71
+ const payload =
72
+ this.controller.rowDrag?.(node, this.controller.model.dynamicState.get(node.id) ?? {}) ??
73
+ null;
74
+ const orderPart = ['rowDrag', 'rowUp', 'rowDown'].includes(hit.part);
75
+ if (orderPart && node.reorderable === false) return;
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
+ });
49
85
  this.controller.cellEditor?.close?.();
50
86
  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 };
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
+ };
53
97
  this.canvas.setPointerCapture?.(event.pointerId);
54
- event.preventDefault();
98
+ event.stopPropagation();
99
+ if (orderPart) event.preventDefault();
55
100
  };
56
101
 
57
- onMove = event => {
102
+ onMove = (event) => {
58
103
  const gesture = this.gesture;
59
104
  if (!gesture || event.pointerId !== gesture.pointerId) return;
60
105
  gesture.point = this.point(event);
61
106
  if (gesture.part !== 'rowDrag') return;
62
- 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;
112
+ if (!gesture.dragging && gesture.payload != null) this.emitDrag('rowdragstart', event);
63
113
  gesture.dragging = true;
114
+ if (gesture.payload != null) this.emitDrag('rowdragmove', event);
64
115
  this.canvas.style.cursor = 'grabbing';
65
116
  this.updateTarget();
66
117
  this.scheduleScroll();
67
118
  event.preventDefault();
68
119
  };
69
120
 
70
- onUp = event => {
121
+ onUp = (event) => {
71
122
  const gesture = this.gesture;
72
123
  if (!gesture || event.pointerId !== gesture.pointerId) return;
73
124
  gesture.point = this.point(event);
74
- 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;
75
128
  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' });
129
+ if (gesture.dragging && gesture.payload != null) this.emitDrag('rowdragend', event);
130
+ this.suppressClick = gesture.dragging;
131
+ this.cancel(false);
132
+ if (target)
133
+ this.controller.moveRow(gesture.id, target.index, {
134
+ source: 'pointer'
135
+ });
78
136
  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' });
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
+ });
81
145
  }
82
146
  };
83
147
 
84
- onCancel = event => { if (this.gesture?.pointerId === event.pointerId) this.cancel(); };
85
- onKey = event => {
148
+ onCancel = (event) => {
149
+ if (this.gesture?.pointerId === event.pointerId) this.cancel();
150
+ };
151
+ onKey = (event) => {
86
152
  if (event.key === 'Escape' && this.gesture) {
87
153
  this.cancel();
88
154
  event.preventDefault();
@@ -91,23 +157,38 @@ export class RowReorderInput {
91
157
 
92
158
  point(event) {
93
159
  const rect = this.canvas.getBoundingClientRect();
94
- 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
+ };
95
164
  }
96
165
 
97
166
  updateTarget() {
98
167
  const gesture = this.gesture;
99
- 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
+ );
100
173
  }
101
174
 
102
175
  scheduleScroll() {
103
176
  if (this.frame !== null || !this.gesture?.dragging) return;
104
177
  const viewport = this.controller.viewport;
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;
105
185
  const y = this.gesture.point.y - viewport.renderInsetY;
106
186
  const top = viewport.headerHeight;
107
187
  const bottom = top + viewport.rowViewportHeight;
108
188
  const delta = y < top + 24 ? -10 : y > bottom - 24 ? 10 : 0;
109
189
  if (!delta) return;
110
- 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));
111
192
  this.frame = schedule(() => {
112
193
  this.frame = null;
113
194
  if (!this.gesture?.dragging) return;
@@ -118,12 +199,27 @@ export class RowReorderInput {
118
199
  });
119
200
  }
120
201
 
121
- cancel = () => {
202
+ emitDrag(type, originalEvent) {
122
203
  const gesture = this.gesture;
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
+ });
211
+ }
212
+
213
+ cancel = (notify = true) => {
214
+ const gesture = this.gesture;
215
+ if (gesture?.dragging) this.suppressClick = true;
216
+ if (notify && gesture?.dragging && gesture.payload != null) this.emitDrag('rowdragcancel');
123
217
  this.gesture = null;
124
- 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);
125
220
  this.frame = null;
126
- 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);
127
223
  this.canvas.style.cursor = '';
128
224
  this.controller.setRowDrop(null);
129
225
  };
@@ -132,6 +228,7 @@ export class RowReorderInput {
132
228
  this.cancel();
133
229
  this.canvas.removeEventListener('pointerdown', this.onDown);
134
230
  this.canvas.removeEventListener('keydown', this.onKey);
231
+ this.canvas.removeEventListener('lostpointercapture', this.onCancel);
135
232
  this.view.removeEventListener('pointermove', this.onMove);
136
233
  this.view.removeEventListener('pointerup', this.onUp);
137
234
  this.view.removeEventListener('pointercancel', this.onCancel);
@@ -140,4 +237,5 @@ export class RowReorderInput {
140
237
  for (const stop of this.stops) stop();
141
238
  this.status?.remove();
142
239
  }
240
+
143
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
+ }