virtual-tree-canvas 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +115 -28
- package/docs/icon-catalog.html +67 -0
- package/docs/icon-catalog.json +380 -0
- package/docs/icon-catalog.md +167 -0
- package/examples/generate-tree.js +86 -0
- package/examples/reorder-table.html +33 -0
- package/examples/units.html +43 -0
- package/package.json +7 -3
- package/resources/icons/bicycle.svg +1 -0
- package/resources/icons/bird.svg +1 -0
- package/resources/icons/box.svg +1 -0
- package/resources/icons/building.svg +1 -0
- package/resources/icons/calendar.svg +1 -0
- package/resources/icons/car.svg +1 -0
- package/resources/icons/cat.svg +1 -0
- package/resources/icons/child.svg +1 -0
- package/resources/icons/clock.svg +1 -0
- package/resources/icons/cloud.svg +1 -0
- package/resources/icons/data-bus.svg +1 -0
- package/resources/icons/database.svg +1 -0
- package/resources/icons/dog.svg +1 -0
- package/resources/icons/drone.svg +1 -0
- package/resources/icons/fish.svg +1 -0
- package/resources/icons/flower.svg +1 -0
- package/resources/icons/fog.svg +1 -0
- package/resources/icons/globe.svg +1 -0
- package/resources/icons/grip.svg +1 -0
- package/resources/icons/heart.svg +1 -0
- package/resources/icons/helicopter.svg +1 -0
- package/resources/icons/insect.svg +1 -0
- package/resources/icons/leaf.svg +1 -0
- package/resources/icons/link.svg +1 -0
- package/resources/icons/lock.svg +1 -0
- package/resources/icons/moon.svg +1 -0
- package/resources/icons/network.svg +1 -0
- package/resources/icons/people.svg +1 -0
- package/resources/icons/person.svg +1 -0
- package/resources/icons/pin.svg +1 -0
- package/resources/icons/radar.svg +1 -0
- package/resources/icons/rain.svg +1 -0
- package/resources/icons/seedling.svg +1 -0
- package/resources/icons/sensor.svg +1 -0
- package/resources/icons/server.svg +1 -0
- package/resources/icons/ship.svg +1 -0
- package/resources/icons/snow.svg +1 -0
- package/resources/icons/star.svg +1 -0
- package/resources/icons/storm.svg +1 -0
- package/resources/icons/sun.svg +1 -0
- package/resources/icons/thermometer.svg +1 -0
- package/resources/icons/tree.svg +1 -0
- package/resources/icons/wind.svg +1 -0
- package/src/assets/icons.js +73 -25
- package/src/core/icon-registry.js +6 -8
- package/src/core/theme-manager.js +15 -4
- package/src/core/tree-column-model.js +16 -3
- package/src/core/tree-model.js +20 -0
- package/src/core/view-options.js +30 -0
- package/src/index.d.ts +166 -5
- package/src/index.js +3 -0
- package/src/input/index.js +1 -0
- package/src/input/row-reorder-input.js +143 -0
- package/src/input/tree-filter-bar.js +55 -0
- package/src/input/tree-tooltip.js +55 -0
- package/src/input/tree-view-input-controller.js +5 -0
- package/src/inspector/cell-editor-manager.js +42 -22
- package/src/inspector/editor-resolver.js +6 -1
- package/src/inspector/numeric-layout.js +16 -0
- package/src/renderers/tree-row-renderer.js +79 -13
- package/src/tree-view-controller.js +261 -34
- package/src/tree-view.js +181 -0
- package/src/view/styles.js +14 -0
- package/src/assets/icons/air.svg +0 -1
- package/src/assets/icons/radar.svg +0 -1
- /package/{src/assets → resources}/icons/aircraft.svg +0 -0
- /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
- /package/{src/assets → resources}/icons/control.svg +0 -0
- /package/{src/assets → resources}/icons/damage.svg +0 -0
- /package/{src/assets → resources}/icons/error.svg +0 -0
- /package/{src/assets → resources}/icons/folder.svg +0 -0
- /package/{src/assets → resources}/icons/ground.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
- /package/{src/assets → resources}/icons/munition.svg +0 -0
- /package/{src/assets → resources}/icons/placeholder.svg +0 -0
- /package/{src/assets → resources}/icons/point.svg +0 -0
- /package/{src/assets → resources}/icons/situation.svg +0 -0
- /package/{src/assets → resources}/icons/space.svg +0 -0
- /package/{src/assets → resources}/icons/subsurface.svg +0 -0
- /package/{src/assets → resources}/icons/surface.svg +0 -0
- /package/{src/assets → resources}/icons/task.svg +0 -0
- /package/{src/assets → resources}/icons/track.svg +0 -0
- /package/{src/assets → resources}/icons/warning.svg +0 -0
package/src/index.d.ts
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
+
export const builtinIconNames: readonly string[];
|
|
2
|
+
export interface RowReorderDetail {
|
|
3
|
+
nodeId: string;
|
|
4
|
+
parentId: string | null;
|
|
5
|
+
fromIndex: number;
|
|
6
|
+
toIndex: number;
|
|
7
|
+
siblingOrder: string[];
|
|
8
|
+
order: string[];
|
|
9
|
+
source: 'api' | 'pointer' | 'button' | 'keyboard';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TreeViewOptions {
|
|
13
|
+
nativeScrollbars?: boolean;
|
|
14
|
+
iconsBaseUrl?: string | URL;
|
|
15
|
+
iconRegistry?: IconRegistry;
|
|
16
|
+
rowReorder?: boolean;
|
|
17
|
+
autoRender?: boolean;
|
|
18
|
+
tooltip?: boolean;
|
|
19
|
+
canvas?: HTMLCanvasElement;
|
|
20
|
+
host?: HTMLElement;
|
|
21
|
+
[key: string]: any;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class TreeTooltip {
|
|
25
|
+
constructor(options: { controller: TreeViewController; host?: HTMLElement });
|
|
26
|
+
hide(): void;
|
|
27
|
+
destroy(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
1
30
|
export type TreeViewAlign = 'start' | 'center' | 'end' | 'nearest';
|
|
2
31
|
|
|
3
32
|
export type IconDrawFunction = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) => void;
|
|
4
33
|
export type IconSource = string | CanvasImageSource | IconDrawFunction;
|
|
5
34
|
|
|
6
35
|
export class IconRegistry {
|
|
7
|
-
constructor(options?: { pixelRatio?: number });
|
|
36
|
+
constructor(options?: { pixelRatio?: number; iconsBaseUrl?: string | URL });
|
|
8
37
|
register(name: string, icon: IconSource): any;
|
|
9
38
|
get(name: string): any;
|
|
10
39
|
onChange(listener: () => void): () => void;
|
|
@@ -33,6 +62,10 @@ export type MetaRule = {
|
|
|
33
62
|
max?: number;
|
|
34
63
|
step?: number;
|
|
35
64
|
integer?: boolean;
|
|
65
|
+
/** Display-only suffix for numeric values. Never appended to the model value. */
|
|
66
|
+
unit?: string;
|
|
67
|
+
/** Display decimal places (0–20). Editing retains the original precision. */
|
|
68
|
+
precision?: number;
|
|
36
69
|
readonly?: boolean;
|
|
37
70
|
disabled?: boolean;
|
|
38
71
|
updated?: boolean;
|
|
@@ -85,16 +118,46 @@ export class TreeViewController {
|
|
|
85
118
|
rowModel: any;
|
|
86
119
|
expansion: any;
|
|
87
120
|
selection: any;
|
|
88
|
-
constructor(options?:
|
|
121
|
+
constructor(options?: TreeViewOptions);
|
|
122
|
+
iconRegistry: IconRegistry;
|
|
123
|
+
tooltip?: TreeTooltip | null;
|
|
124
|
+
rowReorder: boolean;
|
|
125
|
+
editable: boolean;
|
|
126
|
+
initialExpandDepth: number;
|
|
127
|
+
filterOptions: {caseSensitive: boolean; wholeWord: boolean};
|
|
128
|
+
model: any;
|
|
129
|
+
inspector: any;
|
|
130
|
+
columnModel: any;
|
|
131
|
+
setEditable(enabled: boolean): void;
|
|
132
|
+
setInitialExpandDepth(depth: number): void;
|
|
133
|
+
setIconResolver(resolver: TreeViewConfiguration['iconResolver']): void;
|
|
134
|
+
setInspectorOptions(options: {filter?: boolean; markUpdated?: boolean}): void;
|
|
135
|
+
setHeaderFilter(enabled: boolean): void;
|
|
136
|
+
setInspectorValue(path: string, value: any, options?: InspectorWriteOptions): boolean;
|
|
137
|
+
updateInspectorValue(nodeId: string, value: any, editorType?: string, options?: InspectorWriteOptions): boolean;
|
|
138
|
+
closeEditor(): void;
|
|
139
|
+
clearSearch(): void;
|
|
140
|
+
getSearchState(): TreeSearchState;
|
|
141
|
+
nextSearchResult(): string | null;
|
|
142
|
+
previousSearchResult(): string | null;
|
|
143
|
+
setRowReorder(enabled: boolean): void;
|
|
144
|
+
canReorderRows(): boolean;
|
|
145
|
+
getRowOrder(parentId?: string | null): string[];
|
|
146
|
+
moveRow(nodeId: string, targetIndex: number, options?: {source?: RowReorderDetail["source"]}): boolean;
|
|
147
|
+
moveRowBy(nodeId: string, offset: number, options?: {source?: RowReorderDetail["source"]}): boolean;
|
|
148
|
+
requestRender(force?: boolean): void;
|
|
149
|
+
cancelRender(): void;
|
|
150
|
+
attachTooltip(options?: {host?: HTMLElement}): TreeTooltip;
|
|
89
151
|
initialize(canvas: HTMLCanvasElement): this;
|
|
90
152
|
attachCellEditor(options?: { host?: HTMLElement | null }): CellEditorManager;
|
|
91
153
|
attachInput(options?: { cellEditor?: CellEditorManager | null }): TreeViewInputController;
|
|
92
154
|
destroy(): void;
|
|
93
|
-
on(type:
|
|
155
|
+
on<K extends keyof TreeViewEvents>(type: K, listener: (event: TreeViewEvent<K>) => void): () => void;
|
|
156
|
+
on(type: string, listener: (event: any) => void): () => void;
|
|
94
157
|
off(type: string, listener: (event: any) => void): void;
|
|
95
|
-
setData(nodes: TreeNode[]): void;
|
|
158
|
+
setData(nodes: TreeNode[], options?: { iconResolver?: TreeViewConfiguration['iconResolver'] }): void;
|
|
96
159
|
setModel(model: any, meta?: Record<string, MetaRule>, options?: Record<string, any>): void;
|
|
97
|
-
setColumns(columns: Column[]): void;
|
|
160
|
+
setColumns(columns: Column[] | null): void;
|
|
98
161
|
setDynamicState(patches: DynamicPatch[]): void;
|
|
99
162
|
setTheme(theme: any): void;
|
|
100
163
|
setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
|
|
@@ -125,5 +188,103 @@ export class TreeViewInputController {
|
|
|
125
188
|
export class CellEditorManager {
|
|
126
189
|
constructor(options: { controller: TreeViewController; host?: HTMLElement | null });
|
|
127
190
|
destroy(): void;
|
|
191
|
+
setEditable(enabled: boolean): void;
|
|
128
192
|
close(): void;
|
|
129
193
|
}
|
|
194
|
+
|
|
195
|
+
export interface InspectorWriteOptions { emit?: boolean; source?: string }
|
|
196
|
+
export interface TreeViewConfiguration {
|
|
197
|
+
mode?: 'tree' | 'inspector';
|
|
198
|
+
presentation?: 'pane' | 'table';
|
|
199
|
+
nodes?: TreeNode[];
|
|
200
|
+
model?: any;
|
|
201
|
+
meta?: Record<string, MetaRule> | null;
|
|
202
|
+
columns?: Column[] | null;
|
|
203
|
+
theme?: string | Record<string, any>;
|
|
204
|
+
flatRoot?: boolean;
|
|
205
|
+
enforceMeta?: boolean;
|
|
206
|
+
filter?: boolean;
|
|
207
|
+
filterPlacement?: 'auto' | 'bar' | 'header';
|
|
208
|
+
markUpdated?: boolean;
|
|
209
|
+
editable?: boolean;
|
|
210
|
+
rowReorder?: boolean;
|
|
211
|
+
initialExpandDepth?: number;
|
|
212
|
+
rowHeight?: number;
|
|
213
|
+
indentWidth?: number;
|
|
214
|
+
headerHeight?: number;
|
|
215
|
+
iconResolver?: ((node: TreeNode) => string | Partial<TreeNode> | null | undefined) | null;
|
|
216
|
+
fontFamily?: string | null;
|
|
217
|
+
}
|
|
218
|
+
export const treeViewOptionNames: readonly (keyof TreeViewConfiguration)[];
|
|
219
|
+
export function validateTreeViewOptions(options: TreeViewConfiguration): void;
|
|
220
|
+
|
|
221
|
+
export class TreeView {
|
|
222
|
+
constructor(host: HTMLElement, options?: TreeViewConfiguration & Pick<TreeViewOptions, 'iconsBaseUrl' | 'iconRegistry' | 'nativeScrollbars'>);
|
|
223
|
+
readonly controller: TreeViewController;
|
|
224
|
+
readonly element: HTMLDivElement;
|
|
225
|
+
readonly canvas: HTMLCanvasElement;
|
|
226
|
+
readonly destroyed: boolean;
|
|
227
|
+
configure(options: TreeViewConfiguration): this;
|
|
228
|
+
flush(): void;
|
|
229
|
+
destroy(): void;
|
|
230
|
+
setData(nodes: TreeNode[]): void;
|
|
231
|
+
setModel(model: any, meta?: Record<string, MetaRule>): void;
|
|
232
|
+
setColumns(columns: Column[] | null): void;
|
|
233
|
+
setTheme(theme: string | Record<string, any>): void;
|
|
234
|
+
setDynamicState(patches: DynamicPatch[]): void;
|
|
235
|
+
setInspectorValue(path: string, value: any, options?: InspectorWriteOptions): boolean;
|
|
236
|
+
on<K extends keyof TreeViewEvents>(type: K, listener: (event: TreeViewEvent<K>) => void): () => void;
|
|
237
|
+
on(type: string, listener: (event: any) => void): () => void;
|
|
238
|
+
off(type: string, listener: (event: any) => void): void;
|
|
239
|
+
setFilter(query?: string | ((node: TreeNode, state: Record<string, any>) => boolean), options?: {caseSensitive?: boolean; wholeWord?: boolean}): void;
|
|
240
|
+
clearFilter(): void;
|
|
241
|
+
getRowOrder(parentId?: string | null): string[];
|
|
242
|
+
moveRow(id: string, index: number, options?: {source?: RowReorderDetail['source']}): boolean;
|
|
243
|
+
moveRowBy(id: string, offset: number, options?: {source?: RowReorderDetail['source']}): boolean;
|
|
244
|
+
getSelection(): string[];
|
|
245
|
+
setSelection(ids: string[]): void;
|
|
246
|
+
clearSelection(): void;
|
|
247
|
+
search(query: string, options?: Record<string, any>): any;
|
|
248
|
+
clearSearch(): void;
|
|
249
|
+
getSearchState(): TreeSearchState;
|
|
250
|
+
nextSearchResult(): string | null;
|
|
251
|
+
previousSearchResult(): string | null;
|
|
252
|
+
focusNode(id: string, options?: Record<string, any>): boolean;
|
|
253
|
+
scrollToNode(id: string, align?: TreeViewAlign): boolean;
|
|
254
|
+
expandAll(): void;
|
|
255
|
+
collapseAll(): void;
|
|
256
|
+
registerIcon(name: string, source: IconSource): any;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Public event details shared by the DOM view and the controller. */
|
|
260
|
+
export interface InspectorValueChange {
|
|
261
|
+
path: string;
|
|
262
|
+
oldValue: any;
|
|
263
|
+
newValue: any;
|
|
264
|
+
model: any;
|
|
265
|
+
nodeId: string;
|
|
266
|
+
editorType: string;
|
|
267
|
+
source: string;
|
|
268
|
+
}
|
|
269
|
+
export interface TreeSearchState {
|
|
270
|
+
query: string;
|
|
271
|
+
results: string[];
|
|
272
|
+
cursor: number;
|
|
273
|
+
current: string | null;
|
|
274
|
+
count: number;
|
|
275
|
+
}
|
|
276
|
+
export interface TreeViewEvents {
|
|
277
|
+
valuechange: InspectorValueChange;
|
|
278
|
+
modelchange: {model: any; meta?: Record<string, MetaRule>; path?: string; structural?: boolean; action?: string; value?: any} & Partial<InspectorValueChange>;
|
|
279
|
+
action: {path: string; label: string; nodeId: string; model: any; source: string};
|
|
280
|
+
rowreorder: RowReorderDetail;
|
|
281
|
+
selectionchange: {selection: string[]; focusedId: string | null};
|
|
282
|
+
focuschange: {nodeId: string | null};
|
|
283
|
+
filterchange: {query: string; options: {caseSensitive: boolean; wholeWord: boolean}; visibleRows: number; worker?: boolean};
|
|
284
|
+
searchchange: Omit<TreeSearchState, 'count'>;
|
|
285
|
+
nodeclick: {nodeId: string; row: any; originalEvent: any};
|
|
286
|
+
nodedblclick: {nodeId: string; row: any; originalEvent: any};
|
|
287
|
+
editablechange: {editable: boolean};
|
|
288
|
+
rowreorderchange: {enabled: boolean};
|
|
289
|
+
}
|
|
290
|
+
export type TreeViewEvent<K extends keyof TreeViewEvents> = {type: K; detail: TreeViewEvents[K]};
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,6 @@ export * from './inspector/index.js';
|
|
|
4
4
|
export * from './renderers/index.js';
|
|
5
5
|
export * from './tree-view-controller.js';
|
|
6
6
|
export * from './benchmark/index.js';
|
|
7
|
+
export { builtinIconNames } from './assets/icons.js';
|
|
8
|
+
export { TreeView } from './tree-view.js';
|
|
9
|
+
export { treeViewOptionNames, validateTreeViewOptions } from './core/view-options.js';
|
package/src/input/index.js
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
let nextStatusId = 0;
|
|
2
|
+
|
|
3
|
+
/** Pointer interaction for the optional row-order column; persistence belongs to the host. */
|
|
4
|
+
export class RowReorderInput {
|
|
5
|
+
constructor(controller) {
|
|
6
|
+
this.controller = controller;
|
|
7
|
+
this.canvas = controller.canvas;
|
|
8
|
+
this.view = this.canvas.ownerDocument?.defaultView ?? globalThis.window;
|
|
9
|
+
this.gesture = null;
|
|
10
|
+
this.frame = null;
|
|
11
|
+
this.previousTouchAction = this.canvas.style.touchAction;
|
|
12
|
+
this.canvas.addEventListener('pointerdown', this.onDown);
|
|
13
|
+
this.canvas.addEventListener('keydown', this.onKey);
|
|
14
|
+
this.view.addEventListener('pointermove', this.onMove);
|
|
15
|
+
this.view.addEventListener('pointerup', this.onUp);
|
|
16
|
+
this.view.addEventListener('pointercancel', this.onCancel);
|
|
17
|
+
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
|
+
}));
|
|
28
|
+
const doc = this.canvas.ownerDocument;
|
|
29
|
+
if (doc?.createElement && this.canvas.parentElement) {
|
|
30
|
+
this.status = doc.createElement('span');
|
|
31
|
+
this.status.id = `vtc-row-order-status-${nextStatusId++}`;
|
|
32
|
+
this.status.setAttribute('aria-live', 'polite');
|
|
33
|
+
Object.assign(this.status.style, { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' });
|
|
34
|
+
this.canvas.parentElement.appendChild(this.status);
|
|
35
|
+
}
|
|
36
|
+
this.syncMode();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
syncMode() {
|
|
40
|
+
this.canvas.style.touchAction = this.controller.canReorderRows() ? 'none' : this.previousTouchAction;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onDown = event => {
|
|
44
|
+
if (event.button !== 0 || !this.controller.canReorderRows() || this.gesture) return;
|
|
45
|
+
const point = this.point(event);
|
|
46
|
+
const hit = this.controller.hitTest(point.x, point.y);
|
|
47
|
+
if (!hit?.row || !['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
|
|
48
|
+
this.canvas.focus();
|
|
49
|
+
this.controller.cellEditor?.close?.();
|
|
50
|
+
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 };
|
|
53
|
+
this.canvas.setPointerCapture?.(event.pointerId);
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
onMove = event => {
|
|
58
|
+
const gesture = this.gesture;
|
|
59
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
60
|
+
gesture.point = this.point(event);
|
|
61
|
+
if (gesture.part !== 'rowDrag') return;
|
|
62
|
+
if (!gesture.dragging && Math.hypot(gesture.point.x - gesture.startX, gesture.point.y - gesture.startY) < 5) return;
|
|
63
|
+
gesture.dragging = true;
|
|
64
|
+
this.canvas.style.cursor = 'grabbing';
|
|
65
|
+
this.updateTarget();
|
|
66
|
+
this.scheduleScroll();
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
onUp = event => {
|
|
71
|
+
const gesture = this.gesture;
|
|
72
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
73
|
+
gesture.point = this.point(event);
|
|
74
|
+
const target = gesture.dragging ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null;
|
|
75
|
+
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' });
|
|
78
|
+
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' });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
onCancel = event => { if (this.gesture?.pointerId === event.pointerId) this.cancel(); };
|
|
85
|
+
onKey = event => {
|
|
86
|
+
if (event.key === 'Escape' && this.gesture) {
|
|
87
|
+
this.cancel();
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
point(event) {
|
|
93
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
94
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
updateTarget() {
|
|
98
|
+
const gesture = this.gesture;
|
|
99
|
+
this.controller.setRowDrop(gesture ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
scheduleScroll() {
|
|
103
|
+
if (this.frame !== null || !this.gesture?.dragging) return;
|
|
104
|
+
const viewport = this.controller.viewport;
|
|
105
|
+
const y = this.gesture.point.y - viewport.renderInsetY;
|
|
106
|
+
const top = viewport.headerHeight;
|
|
107
|
+
const bottom = top + viewport.rowViewportHeight;
|
|
108
|
+
const delta = y < top + 24 ? -10 : y > bottom - 24 ? 10 : 0;
|
|
109
|
+
if (!delta) return;
|
|
110
|
+
const schedule = this.view.requestAnimationFrame?.bind(this.view) ?? (callback => setTimeout(callback, 16));
|
|
111
|
+
this.frame = schedule(() => {
|
|
112
|
+
this.frame = null;
|
|
113
|
+
if (!this.gesture?.dragging) return;
|
|
114
|
+
const previous = viewport.scrollY;
|
|
115
|
+
this.controller.scrollBy(0, delta);
|
|
116
|
+
this.updateTarget();
|
|
117
|
+
if (viewport.scrollY !== previous) this.scheduleScroll();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
cancel = () => {
|
|
122
|
+
const gesture = this.gesture;
|
|
123
|
+
this.gesture = null;
|
|
124
|
+
if (this.frame !== null) (this.view.cancelAnimationFrame?.bind(this.view) ?? clearTimeout)(this.frame);
|
|
125
|
+
this.frame = null;
|
|
126
|
+
if (gesture && this.canvas.hasPointerCapture?.(gesture.pointerId)) this.canvas.releasePointerCapture(gesture.pointerId);
|
|
127
|
+
this.canvas.style.cursor = '';
|
|
128
|
+
this.controller.setRowDrop(null);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
destroy() {
|
|
132
|
+
this.cancel();
|
|
133
|
+
this.canvas.removeEventListener('pointerdown', this.onDown);
|
|
134
|
+
this.canvas.removeEventListener('keydown', this.onKey);
|
|
135
|
+
this.view.removeEventListener('pointermove', this.onMove);
|
|
136
|
+
this.view.removeEventListener('pointerup', this.onUp);
|
|
137
|
+
this.view.removeEventListener('pointercancel', this.onCancel);
|
|
138
|
+
this.view.removeEventListener('blur', this.cancel);
|
|
139
|
+
this.canvas.style.touchAction = this.previousTouchAction;
|
|
140
|
+
for (const stop of this.stops) stop();
|
|
141
|
+
this.status?.remove();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** A view of controller filter state. No duplicated query or toggle state. */
|
|
2
|
+
export class TreeFilterBar {
|
|
3
|
+
constructor(controller, document) {
|
|
4
|
+
this.controller = controller;
|
|
5
|
+
this.element = document.createElement('div');
|
|
6
|
+
this.element.className = 'vtc-filter';
|
|
7
|
+
this.input = document.createElement('input');
|
|
8
|
+
this.input.type = 'search';
|
|
9
|
+
this.input.placeholder = 'Filter';
|
|
10
|
+
this.input.setAttribute('aria-label', 'Filter rows');
|
|
11
|
+
this.caseButton = this.button(document, 'Aa', 'Match case');
|
|
12
|
+
this.wholeButton = this.button(document, 'W', 'Match whole word');
|
|
13
|
+
this.element.append(this.input, this.caseButton, this.wholeButton);
|
|
14
|
+
this.input.addEventListener('input', this.onInput);
|
|
15
|
+
this.caseButton.addEventListener('click', this.onCase);
|
|
16
|
+
this.wholeButton.addEventListener('click', this.onWhole);
|
|
17
|
+
this.stop = controller.on('filterchange', () => this.sync());
|
|
18
|
+
this.sync();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
button(document, label, title) {
|
|
22
|
+
const button = document.createElement('button');
|
|
23
|
+
button.type = 'button';
|
|
24
|
+
button.textContent = label;
|
|
25
|
+
button.title = title;
|
|
26
|
+
button.setAttribute('aria-label', title);
|
|
27
|
+
return button;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onInput = () => this.controller.setFilter(this.input.value, this.controller.filterOptions);
|
|
31
|
+
onCase = () => this.toggle('caseSensitive');
|
|
32
|
+
onWhole = () => this.toggle('wholeWord');
|
|
33
|
+
|
|
34
|
+
toggle(key) {
|
|
35
|
+
const options = this.controller.filterOptions;
|
|
36
|
+
this.controller.setFilter(this.input.value, { ...options, [key]: !options[key] });
|
|
37
|
+
this.input.focus();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
sync() {
|
|
41
|
+
this.input.value = this.controller.filterQuery;
|
|
42
|
+
this.caseButton.setAttribute('aria-pressed', String(this.controller.filterOptions.caseSensitive));
|
|
43
|
+
this.wholeButton.setAttribute('aria-pressed', String(this.controller.filterOptions.wholeWord));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setVisible(visible) { this.element.hidden = !visible; }
|
|
47
|
+
|
|
48
|
+
destroy() {
|
|
49
|
+
this.stop();
|
|
50
|
+
this.input.removeEventListener('input', this.onInput);
|
|
51
|
+
this.caseButton.removeEventListener('click', this.onCase);
|
|
52
|
+
this.wholeButton.removeEventListener('click', this.onWhole);
|
|
53
|
+
this.element.remove();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** DOM tooltip shared by framework adapters and standalone canvas hosts. */
|
|
2
|
+
export class TreeTooltip {
|
|
3
|
+
constructor({ controller, host = controller.canvas?.parentElement }) {
|
|
4
|
+
this.controller = controller;
|
|
5
|
+
this.canvas = controller.canvas;
|
|
6
|
+
this.host = host;
|
|
7
|
+
if (!this.canvas || !host?.ownerDocument) throw new Error('TreeTooltip requires a mounted canvas and host');
|
|
8
|
+
this.element = host.ownerDocument.createElement('div');
|
|
9
|
+
this.element.className = 'virtual-tree-canvas-tooltip';
|
|
10
|
+
this.element.setAttribute('role', 'tooltip');
|
|
11
|
+
Object.assign(this.element.style, {
|
|
12
|
+
position: 'absolute', display: 'none', pointerEvents: 'none', zIndex: '20',
|
|
13
|
+
maxWidth: 'calc(100% - 16px)', padding: '6px 8px', borderRadius: '5px',
|
|
14
|
+
background: 'var(--vtc-tooltip-background, #111827)', color: 'var(--vtc-tooltip-color, #e5e7eb)',
|
|
15
|
+
border: '1px solid var(--vtc-tooltip-border, #374151)',
|
|
16
|
+
font: '12px/1.35 var(--font-family, ui-monospace, monospace)', overflowWrap: 'anywhere'
|
|
17
|
+
});
|
|
18
|
+
host.appendChild(this.element);
|
|
19
|
+
this.canvas.addEventListener('mousemove', this.onMove);
|
|
20
|
+
this.canvas.addEventListener('mouseleave', this.hide);
|
|
21
|
+
this.canvas.addEventListener('wheel', this.hide);
|
|
22
|
+
this.canvas.addEventListener('pointerdown', this.hide);
|
|
23
|
+
this.stops = ['viewportchange', 'filterchange', 'datachange', 'rowdrag'].map(type => controller.on(type, this.hide));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onMove = event => {
|
|
27
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
28
|
+
const hit = this.controller.hitTest(event.clientX - rect.left, event.clientY - rect.top);
|
|
29
|
+
let text;
|
|
30
|
+
if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) {
|
|
31
|
+
text = this.controller.canReorderRows()
|
|
32
|
+
? { rowDrag: 'Drag to reorder. Alt + Up/Down also moves the focused row.', rowUp: 'Move up', rowDown: 'Move down' }[hit.part]
|
|
33
|
+
: 'Clear sorting and filtering to reorder rows.';
|
|
34
|
+
} else text = this.controller.getTooltipForHit(hit)?.text;
|
|
35
|
+
if (!text || this.controller.rowReorderInput?.gesture) return this.hide();
|
|
36
|
+
const hostRect = this.host.getBoundingClientRect();
|
|
37
|
+
this.element.textContent = text;
|
|
38
|
+
this.element.style.display = 'block';
|
|
39
|
+
const x = event.clientX - hostRect.left + 12;
|
|
40
|
+
const y = event.clientY - hostRect.top + 14;
|
|
41
|
+
this.element.style.left = `${Math.max(8, Math.min(x, hostRect.width - this.element.offsetWidth - 8))}px`;
|
|
42
|
+
this.element.style.top = `${Math.max(8, Math.min(y, hostRect.height - this.element.offsetHeight - 8))}px`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
hide = () => { this.element.style.display = 'none'; };
|
|
46
|
+
|
|
47
|
+
destroy() {
|
|
48
|
+
this.canvas.removeEventListener('mousemove', this.onMove);
|
|
49
|
+
this.canvas.removeEventListener('mouseleave', this.hide);
|
|
50
|
+
this.canvas.removeEventListener('wheel', this.hide);
|
|
51
|
+
this.canvas.removeEventListener('pointerdown', this.hide);
|
|
52
|
+
for (const stop of this.stops) stop();
|
|
53
|
+
this.element.remove();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -43,6 +43,7 @@ export class TreeViewInputController {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
#onMouseMove = (event) => {
|
|
46
|
+
if (this.controller.rowReorderInput?.gesture) return;
|
|
46
47
|
if (this.resizeDrag) {
|
|
47
48
|
const rect = this.canvas.getBoundingClientRect();
|
|
48
49
|
const clientX = event.clientX - rect.left;
|
|
@@ -73,6 +74,7 @@ export class TreeViewInputController {
|
|
|
73
74
|
this.canvas.focus();
|
|
74
75
|
const hit = this.#hitTest(event);
|
|
75
76
|
if (!hit) return;
|
|
77
|
+
if (['rowDrag', 'rowUp', 'rowDown'].includes(hit.part)) return;
|
|
76
78
|
if (hit.area === 'header') {
|
|
77
79
|
if (hit.part === 'filter' && this.cellEditor?.handleHeaderClick(event, hit)) return;
|
|
78
80
|
if (!this.resizeDrag && hit.part === 'label' && hit.column) this.controller.sortBy(hit.column.id);
|
|
@@ -98,6 +100,7 @@ export class TreeViewInputController {
|
|
|
98
100
|
|
|
99
101
|
#onMouseDown = (event) => {
|
|
100
102
|
const hit = this.#hitTest(event);
|
|
103
|
+
if (['rowDrag', 'rowUp', 'rowDown'].includes(hit?.part)) return;
|
|
101
104
|
this.controller.setActiveHit(hit);
|
|
102
105
|
if (this.cellEditor?.handlePointerDown(event, hit)) {
|
|
103
106
|
event.preventDefault();
|
|
@@ -135,6 +138,8 @@ export class TreeViewInputController {
|
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
function cursorForHit(hit) {
|
|
141
|
+
if (hit?.part === 'rowDrag') return 'grab';
|
|
142
|
+
if (hit?.part === 'rowUp' || hit?.part === 'rowDown') return 'pointer';
|
|
138
143
|
if (hit?.area === 'header' && hit.part === 'resize') return 'col-resize';
|
|
139
144
|
if (hit?.area === 'header' && hit.part === 'filter') return 'text';
|
|
140
145
|
if (hit?.area !== 'row') return '';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { numericLayout } from './numeric-layout.js';
|
|
2
|
+
|
|
1
3
|
export class CellEditorManager {
|
|
2
4
|
constructor({ controller, host = null }) {
|
|
3
5
|
if (!controller) throw new TypeError('CellEditorManager requires a TreeViewController');
|
|
@@ -5,13 +7,24 @@ export class CellEditorManager {
|
|
|
5
7
|
this.controller = controller;
|
|
6
8
|
this.canvas = controller.canvas;
|
|
7
9
|
this.host = host ?? controller.canvas.parentElement ?? document.body;
|
|
10
|
+
this.editable = true;
|
|
8
11
|
this.overlay = null;
|
|
9
12
|
this.rangeDrag = null;
|
|
13
|
+
this.overlayKind = null;
|
|
14
|
+
this.stopFilter = controller.on('filterchange', ({ detail }) => {
|
|
15
|
+
if (this.overlayKind === 'filter' && this.overlay) this.overlay.value = detail.query;
|
|
16
|
+
});
|
|
10
17
|
this.onMouseMove = this.#onMouseMove.bind(this);
|
|
11
18
|
this.onMouseUp = this.#onMouseUp.bind(this);
|
|
12
19
|
}
|
|
13
20
|
|
|
21
|
+
setEditable(enabled) {
|
|
22
|
+
this.editable = enabled;
|
|
23
|
+
if (!enabled) this.close();
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
destroy() {
|
|
27
|
+
this.stopFilter();
|
|
15
28
|
this.#removeOverlay();
|
|
16
29
|
window.removeEventListener('mousemove', this.onMouseMove);
|
|
17
30
|
window.removeEventListener('mouseup', this.onMouseUp);
|
|
@@ -25,7 +38,7 @@ export class CellEditorManager {
|
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
handlePointerDown(event, hit) {
|
|
28
|
-
if (!this.#isEditableHit(hit)) return false;
|
|
41
|
+
if (!this.editable || !this.#isEditableHit(hit)) return false;
|
|
29
42
|
const data = hit.row ? this.controller.model.nodes[hit.row.nodeIndex]?.data : null;
|
|
30
43
|
if (!data || data.readonly || data.disabled) return false;
|
|
31
44
|
if (data.editorType === 'range' && hit.part === 'range') {
|
|
@@ -44,7 +57,7 @@ export class CellEditorManager {
|
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
handleClick(event, hit) {
|
|
47
|
-
if (!this.#isEditableHit(hit)) return false;
|
|
60
|
+
if (!this.editable || !this.#isEditableHit(hit)) return false;
|
|
48
61
|
const node = this.controller.model.nodes[hit.row.nodeIndex];
|
|
49
62
|
const data = node?.data;
|
|
50
63
|
if (!data || data.disabled) return false;
|
|
@@ -81,6 +94,7 @@ export class CellEditorManager {
|
|
|
81
94
|
const hostRect = this.host.getBoundingClientRect();
|
|
82
95
|
const element = createEditorElement(data);
|
|
83
96
|
element.className = `vtc-editor vtc-editor-${data.editorType}`;
|
|
97
|
+
element.setAttribute('aria-label', [node.label, numericLayout(rect.width, data).unit].filter(Boolean).join(' '));
|
|
84
98
|
Object.assign(element.style, {
|
|
85
99
|
position: 'absolute',
|
|
86
100
|
left: `${rect.x - hostRect.left}px`,
|
|
@@ -105,7 +119,7 @@ export class CellEditorManager {
|
|
|
105
119
|
});
|
|
106
120
|
let committed = false;
|
|
107
121
|
const commit = () => {
|
|
108
|
-
if (committed) return;
|
|
122
|
+
if (committed || this.overlay !== element) return;
|
|
109
123
|
committed = true;
|
|
110
124
|
const nextValue = parseEditorValue(element, data);
|
|
111
125
|
this.controller.updateInspectorValue(node.id, nextValue, data.editorType);
|
|
@@ -122,6 +136,7 @@ export class CellEditorManager {
|
|
|
122
136
|
ensureOverlayHost(this.host);
|
|
123
137
|
this.host.append(element);
|
|
124
138
|
this.overlay = element;
|
|
139
|
+
this.overlayKind = 'value';
|
|
125
140
|
element.focus({ preventScroll: true });
|
|
126
141
|
element.select?.();
|
|
127
142
|
if (options.showPicker && element.showPicker) {
|
|
@@ -177,6 +192,7 @@ export class CellEditorManager {
|
|
|
177
192
|
ensureOverlayHost(this.host);
|
|
178
193
|
this.host.append(element);
|
|
179
194
|
this.overlay = element;
|
|
195
|
+
this.overlayKind = 'filter';
|
|
180
196
|
element.focus({ preventScroll: true });
|
|
181
197
|
element.select();
|
|
182
198
|
}
|
|
@@ -208,34 +224,36 @@ export class CellEditorManager {
|
|
|
208
224
|
}
|
|
209
225
|
|
|
210
226
|
#isEditableHit(hit) {
|
|
211
|
-
return hit?.area === 'row' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
|
|
227
|
+
return hit?.area === 'row' && hit.part !== 'unit' && (hit.column?.kind === 'inspectorValue' || hit.column?.kind === 'inspectorPane');
|
|
212
228
|
}
|
|
213
229
|
|
|
214
|
-
#
|
|
215
|
-
if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
|
|
230
|
+
#editorContentRect(hit) {
|
|
216
231
|
const rect = this.controller.getCellClientRect(hit);
|
|
232
|
+
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 };
|
|
234
|
+
}
|
|
217
235
|
const visibleWidth = Math.max(1, Math.min(rect.width, this.controller.viewport.contentViewportWidth));
|
|
218
236
|
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
219
237
|
const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
238
|
+
return { x: rect.x + editorLeft + 10, y: rect.y + 4, width: Math.max(24, editorWidth - 20), height: rect.height - 8 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#overlayRect(hit) {
|
|
242
|
+
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
243
|
+
const rect = this.#editorContentRect(hit);
|
|
244
|
+
const layout = numericLayout(rect.width, data);
|
|
245
|
+
if (hit.part === 'number' && data.editorType === 'range') {
|
|
246
|
+
return { ...rect, x: rect.x + layout.numberLeft, width: layout.valueWidth };
|
|
223
247
|
}
|
|
224
|
-
|
|
248
|
+
if (layout.unit) return { ...rect, width: layout.contentWidth };
|
|
249
|
+
if (hit.column?.kind !== 'inspectorPane') return this.controller.getCellClientRect(hit);
|
|
250
|
+
return rect;
|
|
225
251
|
}
|
|
226
252
|
|
|
227
253
|
#rangeBarRect(hit) {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
232
|
-
const { editorLeft, editorWidth } = this.controller.getInspectorPaneLayout(visibleWidth, hit.row, data.editorType);
|
|
233
|
-
const valueWidth = Math.min(64, Math.max(42, (editorWidth - 20) * 0.28));
|
|
234
|
-
const barWidth = Math.max(24, editorWidth - 20 - valueWidth - 8);
|
|
235
|
-
return { x: rect.x + editorLeft + 10, y: rect.y + rect.height / 2 - 4, width: barWidth, height: 8 };
|
|
236
|
-
}
|
|
237
|
-
const valueWidth = Math.min(64, Math.max(42, (rect.width - 20) * 0.28));
|
|
238
|
-
return { x: rect.x + 10, y: rect.y + rect.height / 2 - 4, width: Math.max(24, rect.width - 20 - valueWidth - 8), height: 8 };
|
|
254
|
+
const data = this.controller.model.nodes[hit.row.nodeIndex]?.data ?? {};
|
|
255
|
+
const rect = this.#editorContentRect(hit);
|
|
256
|
+
return { ...rect, y: rect.y + rect.height / 2 - 4, width: numericLayout(rect.width, data).barWidth, height: 8 };
|
|
239
257
|
}
|
|
240
258
|
|
|
241
259
|
#clampRectToHost(rect, inset = 0) {
|
|
@@ -252,8 +270,10 @@ export class CellEditorManager {
|
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
#removeOverlay() {
|
|
255
|
-
this.overlay
|
|
273
|
+
const overlay = this.overlay;
|
|
256
274
|
this.overlay = null;
|
|
275
|
+
this.overlayKind = null;
|
|
276
|
+
overlay?.remove();
|
|
257
277
|
}
|
|
258
278
|
|
|
259
279
|
#toggleRangeMinMax(node, data) {
|
|
@@ -28,6 +28,11 @@ export function formatInspectorValue(value, meta = {}) {
|
|
|
28
28
|
if (value === null) return 'null';
|
|
29
29
|
if (value === undefined) return 'undefined';
|
|
30
30
|
if (typeof value === 'object') return Array.isArray(value) ? `Array(${value.length})` : 'Object';
|
|
31
|
-
if (typeof value === 'number')
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
if (Number.isFinite(value) && Number.isInteger(meta.precision) && meta.precision >= 0 && meta.precision <= 20) {
|
|
33
|
+
return value.toFixed(meta.precision);
|
|
34
|
+
}
|
|
35
|
+
return Number.isInteger(value) ? String(value) : String(Math.round(value * 1000) / 1000);
|
|
36
|
+
}
|
|
32
37
|
return String(value);
|
|
33
38
|
}
|