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.
- package/CHANGELOG.md +32 -0
- package/README.md +90 -3
- package/docs/icon-catalog.html +68 -66
- package/docs/icon-catalog.json +12 -0
- package/docs/icon-catalog.md +111 -107
- package/docs/performance/dynamic-updates-0.7.1.md +38 -0
- package/package.json +3 -2
- package/resources/icons/star-filled.svg +1 -0
- package/resources/icons/trash.svg +1 -0
- package/src/assets/icons.js +12 -4
- package/src/core/dynamic-state.js +19 -29
- package/src/core/patch-batcher.js +4 -0
- package/src/core/theme-manager.js +238 -61
- package/src/core/tree-cell-layout.js +11 -0
- package/src/core/tree-column-model.js +81 -22
- package/src/core/tree-model.js +4 -2
- package/src/core/types.js +1 -0
- package/src/core/view-options.js +98 -18
- package/src/core/visible-row-model.js +51 -21
- package/src/index.d.ts +70 -1
- package/src/input/row-actions.js +248 -0
- package/src/input/row-reorder-input.js +134 -36
- package/src/input/tree-tooltip.js +39 -5
- package/src/input/tree-view-input-controller.js +35 -10
- package/src/inspector/cell-editor-manager.js +128 -35
- package/src/inspector/pane-layout.js +20 -0
- package/src/renderers/checkbox.js +13 -0
- package/src/renderers/tree-row-renderer.js +356 -107
- package/src/tree-view-controller.js +968 -236
- package/src/tree-view.js +218 -54
- package/src/view/styles.js +83 -12
|
@@ -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
|
-
/**
|
|
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 = ['
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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, {
|
|
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 =
|
|
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 ||
|
|
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
|
|
48
|
-
this.
|
|
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 = {
|
|
52
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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.
|
|
77
|
-
|
|
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')
|
|
80
|
-
|
|
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 => {
|
|
85
|
-
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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))
|
|
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.
|
|
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 = [
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|