argent-grid 0.1.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/.github/workflows/pages.yml +68 -0
- package/AGENTS.md +179 -0
- package/README.md +222 -0
- package/demo-app/README.md +70 -0
- package/demo-app/angular.json +78 -0
- package/demo-app/e2e/benchmark.spec.ts +53 -0
- package/demo-app/e2e/demo-page.spec.ts +77 -0
- package/demo-app/e2e/grid-features.spec.ts +269 -0
- package/demo-app/package-lock.json +14023 -0
- package/demo-app/package.json +36 -0
- package/demo-app/playwright-test-menu.js +19 -0
- package/demo-app/playwright.config.ts +23 -0
- package/demo-app/src/app/app.component.ts +10 -0
- package/demo-app/src/app/app.config.ts +13 -0
- package/demo-app/src/app/app.routes.ts +7 -0
- package/demo-app/src/app/demo-page/demo-page.component.css +313 -0
- package/demo-app/src/app/demo-page/demo-page.component.html +124 -0
- package/demo-app/src/app/demo-page/demo-page.component.ts +366 -0
- package/demo-app/src/index.html +19 -0
- package/demo-app/src/main.ts +6 -0
- package/demo-app/tsconfig.json +31 -0
- package/ng-package.json +8 -0
- package/package.json +60 -0
- package/plan.md +131 -0
- package/setup-vitest.ts +18 -0
- package/src/lib/argent-grid.module.ts +21 -0
- package/src/lib/components/argent-grid.component.css +483 -0
- package/src/lib/components/argent-grid.component.html +320 -0
- package/src/lib/components/argent-grid.component.spec.ts +189 -0
- package/src/lib/components/argent-grid.component.ts +1188 -0
- package/src/lib/directives/ag-grid-compatibility.directive.ts +92 -0
- package/src/lib/rendering/canvas-renderer.ts +962 -0
- package/src/lib/rendering/render/blit.spec.ts +453 -0
- package/src/lib/rendering/render/blit.ts +393 -0
- package/src/lib/rendering/render/cells.ts +369 -0
- package/src/lib/rendering/render/index.ts +105 -0
- package/src/lib/rendering/render/lines.ts +363 -0
- package/src/lib/rendering/render/theme.spec.ts +282 -0
- package/src/lib/rendering/render/theme.ts +201 -0
- package/src/lib/rendering/render/types.ts +279 -0
- package/src/lib/rendering/render/walk.spec.ts +360 -0
- package/src/lib/rendering/render/walk.ts +360 -0
- package/src/lib/rendering/utils/damage-tracker.spec.ts +444 -0
- package/src/lib/rendering/utils/damage-tracker.ts +423 -0
- package/src/lib/rendering/utils/index.ts +7 -0
- package/src/lib/services/grid.service.spec.ts +1039 -0
- package/src/lib/services/grid.service.ts +1284 -0
- package/src/lib/types/ag-grid-types.ts +970 -0
- package/src/public-api.ts +22 -0
- package/tsconfig.json +32 -0
- package/tsconfig.lib.json +11 -0
- package/tsconfig.spec.json +8 -0
- package/vitest.config.ts +55 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ElementRef, ViewChild, ChangeDetectionStrategy, AfterViewInit, OnChanges, SimpleChanges, ChangeDetectorRef, Inject } from '@angular/core';
|
|
2
|
+
import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
|
3
|
+
import { GridApi, GridOptions, ColDef, ColGroupDef, IRowNode, Column, CellRange, DefaultMenuItem, MenuItemDef, GetContextMenuItemsParams } from '../types/ag-grid-types';
|
|
4
|
+
import { Subject } from 'rxjs';
|
|
5
|
+
import { takeUntil } from 'rxjs/operators';
|
|
6
|
+
import { GridService } from '../services/grid.service';
|
|
7
|
+
import { CanvasRenderer } from '../rendering/canvas-renderer';
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'argent-grid',
|
|
11
|
+
templateUrl: './argent-grid.component.html',
|
|
12
|
+
styleUrls: ['./argent-grid.component.css'],
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush
|
|
14
|
+
})
|
|
15
|
+
export class ArgentGridComponent<TData = any> implements OnInit, OnDestroy, AfterViewInit, OnChanges {
|
|
16
|
+
@Input() columnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null = null;
|
|
17
|
+
@Input() rowData: TData[] | null = null;
|
|
18
|
+
@Input() gridOptions: GridOptions<TData> | null = null;
|
|
19
|
+
@Input() height = '500px';
|
|
20
|
+
@Input() width = '100%';
|
|
21
|
+
@Input() rowHeight = 32;
|
|
22
|
+
|
|
23
|
+
@Output() gridReady = new EventEmitter<GridApi<TData>>();
|
|
24
|
+
@Output() rowClicked = new EventEmitter<{ data: TData; node: IRowNode<TData> }>();
|
|
25
|
+
@Output() selectionChanged = new EventEmitter<IRowNode<TData>[]>();
|
|
26
|
+
|
|
27
|
+
@ViewChild('gridCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
28
|
+
@ViewChild('viewport') viewportRef!: ElementRef<HTMLDivElement>;
|
|
29
|
+
@ViewChild('headerScrollable') headerScrollableRef!: ElementRef<HTMLDivElement>;
|
|
30
|
+
@ViewChild('headerScrollableFilter') headerScrollableFilterRef!: ElementRef<HTMLDivElement>;
|
|
31
|
+
@ViewChild('editorInput') editorInputRef!: ElementRef<HTMLInputElement>;
|
|
32
|
+
|
|
33
|
+
canvasHeight = 0;
|
|
34
|
+
showOverlay = false;
|
|
35
|
+
private viewportHeight = 500;
|
|
36
|
+
|
|
37
|
+
get totalHeight(): number {
|
|
38
|
+
if (this.gridApi) return this.gridApi.getTotalHeight();
|
|
39
|
+
return (this.rowData?.length || 0) * this.rowHeight;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get totalWidth(): number {
|
|
43
|
+
if (!this.gridApi) return 0;
|
|
44
|
+
return this.gridApi.getAllColumns()
|
|
45
|
+
.filter(col => col.visible)
|
|
46
|
+
.reduce((sum, col) => sum + col.width, 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Selection state
|
|
50
|
+
showSelectionColumn = false;
|
|
51
|
+
selectionColumnWidth = 50;
|
|
52
|
+
isAllSelected = false;
|
|
53
|
+
isIndeterminateSelection = false;
|
|
54
|
+
|
|
55
|
+
trackByColumn(index: number, col: Column | ColDef<TData> | ColGroupDef<TData>): string {
|
|
56
|
+
return (col as any).colId || (col as any).field?.toString() || index.toString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Cell editing state
|
|
60
|
+
isEditing = false;
|
|
61
|
+
editingValue = '';
|
|
62
|
+
editorPosition = { x: 0, y: 0, width: 100, height: 32 };
|
|
63
|
+
private editingRowNode: IRowNode<TData> | null = null;
|
|
64
|
+
private editingColDef: ColDef<TData> | null = null;
|
|
65
|
+
|
|
66
|
+
// Header Menu state
|
|
67
|
+
activeHeaderMenu: Column | ColDef<TData> | ColGroupDef<TData> | null = null;
|
|
68
|
+
headerMenuPosition = { x: 0, y: 0 };
|
|
69
|
+
|
|
70
|
+
// Resizing state
|
|
71
|
+
isResizing = false;
|
|
72
|
+
resizeColumn: Column | null = null;
|
|
73
|
+
private resizeStartX = 0;
|
|
74
|
+
private resizeStartWidth = 0;
|
|
75
|
+
|
|
76
|
+
// Range Selection state
|
|
77
|
+
isRangeSelecting = false;
|
|
78
|
+
private rangeStartCell: { rowIndex: number, colId: string } | null = null;
|
|
79
|
+
|
|
80
|
+
// Side Bar state
|
|
81
|
+
sideBarVisible = false;
|
|
82
|
+
activeToolPanel: 'columns' | 'filters' | null = null;
|
|
83
|
+
|
|
84
|
+
// Context Menu state
|
|
85
|
+
activeContextMenu = false;
|
|
86
|
+
contextMenuPosition = { x: 0, y: 0 };
|
|
87
|
+
contextMenuItems: MenuItemDef[] = [];
|
|
88
|
+
private contextMenuCell: { rowNode: IRowNode<TData>, column: Column } | null = null;
|
|
89
|
+
private initialColumnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null = null;
|
|
90
|
+
|
|
91
|
+
private gridApi!: GridApi<TData>;
|
|
92
|
+
private canvasRenderer!: CanvasRenderer;
|
|
93
|
+
private destroy$ = new Subject<void>();
|
|
94
|
+
private gridService = new GridService<TData>();
|
|
95
|
+
private horizontalScrollListener?: (e: Event) => void;
|
|
96
|
+
private resizeObserver?: ResizeObserver;
|
|
97
|
+
|
|
98
|
+
constructor(@Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef) {}
|
|
99
|
+
|
|
100
|
+
ngOnInit(): void {
|
|
101
|
+
this.initialColumnDefs = this.columnDefs ? JSON.parse(JSON.stringify(this.columnDefs)) : null;
|
|
102
|
+
this.initializeGrid();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
106
|
+
// Handle rowData changes after initialization
|
|
107
|
+
if (changes['rowData'] && !changes['rowData'].firstChange) {
|
|
108
|
+
this.onRowDataChanged(changes['rowData'].currentValue);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Handle columnDefs changes
|
|
112
|
+
if (changes['columnDefs'] && !changes['columnDefs'].firstChange) {
|
|
113
|
+
this.onColumnDefsChanged(changes['columnDefs'].currentValue);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Handle gridOptions changes
|
|
117
|
+
if (changes['gridOptions'] && !changes['gridOptions'].firstChange) {
|
|
118
|
+
this.onGridOptionsChanged(changes['gridOptions'].currentValue);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
ngAfterViewInit(): void {
|
|
123
|
+
// Setup canvas renderer after view is initialized
|
|
124
|
+
if (this.canvasRef && !this.canvasRenderer) {
|
|
125
|
+
this.canvasRenderer = new CanvasRenderer(
|
|
126
|
+
this.canvasRef.nativeElement,
|
|
127
|
+
this.gridApi,
|
|
128
|
+
this.rowHeight
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Wire up cell editing callback
|
|
132
|
+
this.canvasRenderer.onCellDoubleClick = (rowIndex, colId) => {
|
|
133
|
+
this.startEditing(rowIndex, colId);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Wire up row click for selection
|
|
137
|
+
this.canvasRenderer.onRowClick = (rowIndex, event) => {
|
|
138
|
+
this.onRowClick(rowIndex, event);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Range Selection Logic
|
|
142
|
+
this.canvasRenderer.onMouseDown = (event, rowIndex, colId) => {
|
|
143
|
+
if (event.button !== 0 || !colId || rowIndex === -1) return;
|
|
144
|
+
|
|
145
|
+
const rangeSelectionEnabled = this.gridApi?.getGridOption('enableRangeSelection');
|
|
146
|
+
if (!rangeSelectionEnabled) return;
|
|
147
|
+
|
|
148
|
+
this.isRangeSelecting = true;
|
|
149
|
+
this.rangeStartCell = { rowIndex, colId };
|
|
150
|
+
|
|
151
|
+
// Clear previous selection if not holding Shift/Ctrl
|
|
152
|
+
if (!event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
|
153
|
+
this.gridApi?.clearRangeSelection();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
this.canvasRenderer.onMouseMove = (event, rowIndex, colId) => {
|
|
158
|
+
if (!this.isRangeSelecting || !this.rangeStartCell || !colId || rowIndex === -1) return;
|
|
159
|
+
|
|
160
|
+
const start = this.rangeStartCell;
|
|
161
|
+
const end = { rowIndex, colId };
|
|
162
|
+
|
|
163
|
+
const columns = this.canvasRenderer.getAllColumns();
|
|
164
|
+
const startColIdx = columns.findIndex(c => c.colId === start.colId);
|
|
165
|
+
const endColIdx = columns.findIndex(c => c.colId === end.colId);
|
|
166
|
+
|
|
167
|
+
if (startColIdx === -1 || endColIdx === -1) return;
|
|
168
|
+
|
|
169
|
+
const range: CellRange = {
|
|
170
|
+
startRow: Math.min(start.rowIndex, end.rowIndex),
|
|
171
|
+
endRow: Math.max(start.rowIndex, end.rowIndex),
|
|
172
|
+
startColumn: columns[Math.min(startColIdx, endColIdx)].colId,
|
|
173
|
+
endColumn: columns[Math.max(startColIdx, endColIdx)].colId,
|
|
174
|
+
columns: columns.slice(Math.min(startColIdx, endColIdx), Math.max(startColIdx, endColIdx) + 1)
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
this.gridApi?.addCellRange(range);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
this.canvasRenderer.onMouseUp = () => {
|
|
181
|
+
this.isRangeSelecting = false;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Setup viewport dimensions and resize observer
|
|
186
|
+
if (this.viewportRef) {
|
|
187
|
+
const rect = this.viewportRef.nativeElement.getBoundingClientRect();
|
|
188
|
+
this.viewportHeight = rect.height || 500;
|
|
189
|
+
this.canvasRenderer?.setViewportDimensions(rect.width, this.viewportHeight);
|
|
190
|
+
this.canvasRenderer?.setTotalRowCount(this.rowData?.length || 0);
|
|
191
|
+
|
|
192
|
+
// Synchronize horizontal scroll with DOM header
|
|
193
|
+
this.horizontalScrollListener = () => {
|
|
194
|
+
if (this.headerScrollableRef) {
|
|
195
|
+
this.headerScrollableRef.nativeElement.scrollLeft = this.viewportRef.nativeElement.scrollLeft;
|
|
196
|
+
}
|
|
197
|
+
if (this.headerScrollableFilterRef) {
|
|
198
|
+
this.headerScrollableFilterRef.nativeElement.scrollLeft = this.viewportRef.nativeElement.scrollLeft;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
this.viewportRef.nativeElement.addEventListener('scroll', this.horizontalScrollListener, { passive: true });
|
|
203
|
+
|
|
204
|
+
// Add ResizeObserver to handle sidebar toggling and other size changes
|
|
205
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
206
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const { width, height } = entry.contentRect;
|
|
209
|
+
this.viewportHeight = height;
|
|
210
|
+
this.canvasRenderer?.setViewportDimensions(width, height);
|
|
211
|
+
this.canvasRenderer?.render();
|
|
212
|
+
this.cdr.detectChanges();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
this.resizeObserver.observe(this.viewportRef.nativeElement);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
ngOnDestroy(): void {
|
|
221
|
+
this.destroy$.next();
|
|
222
|
+
this.destroy$.complete();
|
|
223
|
+
|
|
224
|
+
// Remove horizontal scroll listener
|
|
225
|
+
if (this.viewportRef && this.horizontalScrollListener) {
|
|
226
|
+
this.viewportRef.nativeElement.removeEventListener('scroll', this.horizontalScrollListener);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.resizeObserver) {
|
|
230
|
+
this.resizeObserver.disconnect();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.gridApi?.destroy();
|
|
234
|
+
this.canvasRenderer?.destroy();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private initializeGrid(): void {
|
|
238
|
+
// Initialize grid API
|
|
239
|
+
this.gridApi = this.gridService.createApi(this.columnDefs, this.rowData, this.gridOptions);
|
|
240
|
+
|
|
241
|
+
// Listen for grid state changes from API (filters, sorts, options)
|
|
242
|
+
this.gridService.gridStateChanged$
|
|
243
|
+
.pipe(takeUntil(this.destroy$))
|
|
244
|
+
.subscribe((event) => {
|
|
245
|
+
if (event.type === 'optionChanged' && event.key === 'sideBar') {
|
|
246
|
+
this.sideBarVisible = !!event.value;
|
|
247
|
+
}
|
|
248
|
+
this.canvasRenderer?.render();
|
|
249
|
+
this.cdr.detectChanges();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Check if any column has checkbox selection
|
|
253
|
+
this.showSelectionColumn = this.columnDefs?.some(col =>
|
|
254
|
+
!('children' in col) && col.checkboxSelection
|
|
255
|
+
) || false;
|
|
256
|
+
|
|
257
|
+
// Canvas renderer will be initialized in ngAfterViewInit
|
|
258
|
+
|
|
259
|
+
// Emit grid ready event
|
|
260
|
+
this.gridReady.emit(this.gridApi);
|
|
261
|
+
|
|
262
|
+
// Sidebar state
|
|
263
|
+
this.sideBarVisible = !!this.gridOptions?.sideBar;
|
|
264
|
+
if (this.sideBarVisible && !this.activeToolPanel) {
|
|
265
|
+
this.activeToolPanel = 'columns';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Update overlay state
|
|
269
|
+
this.showOverlay = !this.rowData || this.rowData.length === 0;
|
|
270
|
+
|
|
271
|
+
// Update selection state
|
|
272
|
+
this.updateSelectionState();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private onRowDataChanged(newData: TData[] | null): void {
|
|
276
|
+
this.rowData = newData;
|
|
277
|
+
|
|
278
|
+
if (this.gridApi) {
|
|
279
|
+
this.gridApi.setRowData(newData || []);
|
|
280
|
+
this.canvasRenderer?.setTotalRowCount(newData?.length || 0);
|
|
281
|
+
this.canvasRenderer?.render();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.showOverlay = !newData || newData.length === 0;
|
|
285
|
+
this.updateSelectionState();
|
|
286
|
+
|
|
287
|
+
// Trigger change detection with OnPush
|
|
288
|
+
this.cdr.detectChanges();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private onColumnDefsChanged(newColumnDefs: (ColDef<TData> | ColGroupDef<TData>)[] | null): void {
|
|
292
|
+
this.columnDefs = newColumnDefs;
|
|
293
|
+
|
|
294
|
+
if (this.gridApi) {
|
|
295
|
+
this.gridApi.setColumnDefs(newColumnDefs);
|
|
296
|
+
this.canvasRenderer?.render();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.cdr.detectChanges();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private onGridOptionsChanged(newOptions: GridOptions<TData> | null): void {
|
|
303
|
+
this.gridOptions = newOptions;
|
|
304
|
+
if (this.gridApi && newOptions) {
|
|
305
|
+
// Update all options in the API
|
|
306
|
+
Object.keys(newOptions).forEach(key => {
|
|
307
|
+
this.gridApi.setGridOption(key as any, (newOptions as any)[key]);
|
|
308
|
+
});
|
|
309
|
+
this.canvasRenderer?.render();
|
|
310
|
+
}
|
|
311
|
+
this.cdr.detectChanges();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getColumnWidth(col: Column | ColDef<TData> | ColGroupDef<TData>): number {
|
|
315
|
+
if ('children' in col) {
|
|
316
|
+
// Column group - sum children widths
|
|
317
|
+
return col.children.reduce((sum, child) => sum + this.getColumnWidth(child), 0);
|
|
318
|
+
}
|
|
319
|
+
return col.width || 150;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
getLeftPinnedColumns(): Column[] {
|
|
323
|
+
if (!this.gridApi) return [];
|
|
324
|
+
return this.gridApi.getAllColumns().filter(col => {
|
|
325
|
+
return col.visible && col.pinned === 'left';
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getRightPinnedColumns(): Column[] {
|
|
330
|
+
if (!this.gridApi) return [];
|
|
331
|
+
return this.gridApi.getAllColumns().filter(col => {
|
|
332
|
+
return col.visible && col.pinned === 'right';
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
getNonPinnedColumns(): Column[] {
|
|
337
|
+
if (!this.gridApi) return [];
|
|
338
|
+
return this.gridApi.getAllColumns().filter(col => {
|
|
339
|
+
return col.visible && !col.pinned;
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
isSortable(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
|
|
344
|
+
// If it has children, it's a group and cannot be sorted directly
|
|
345
|
+
if ('children' in col) return false;
|
|
346
|
+
|
|
347
|
+
// Check if the object itself has sortable property (ColDef)
|
|
348
|
+
if ('sortable' in col && col.sortable !== undefined) {
|
|
349
|
+
return !!col.sortable;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// It's likely a Column object, look up its ColDef
|
|
353
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
354
|
+
return colDef ? (colDef.sortable !== false) : true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
getHeaderName(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
|
|
358
|
+
if ('children' in col) {
|
|
359
|
+
return col.headerName || '';
|
|
360
|
+
}
|
|
361
|
+
return col.headerName || (col as any).field?.toString() || '';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
getSortIndicator(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
|
|
365
|
+
if ('children' in col || !col.sort) {
|
|
366
|
+
return '';
|
|
367
|
+
}
|
|
368
|
+
return col.sort === 'asc' ? '▲' : '▼';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
onHeaderClick(col: Column | ColDef<TData> | ColGroupDef<TData>): void {
|
|
372
|
+
if (!this.isSortable(col) || 'children' in col) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Toggle sort
|
|
377
|
+
const currentSort = col.sort;
|
|
378
|
+
const newSort = currentSort === 'asc' ? 'desc' : currentSort === 'desc' ? null : 'asc';
|
|
379
|
+
|
|
380
|
+
const colId = (col as any).colId || (col as any).field?.toString() || '';
|
|
381
|
+
|
|
382
|
+
// Update the column directly if it's a Column object
|
|
383
|
+
if ('colId' in col && !(col as any).children) {
|
|
384
|
+
(col as any).sort = newSort;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
this.gridApi.setSortModel(newSort ? [{ colId, sort: newSort }] : []);
|
|
388
|
+
this.canvasRenderer?.render();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// --- Header Menu Logic ---
|
|
392
|
+
|
|
393
|
+
hasHeaderMenu(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
|
|
394
|
+
if ('children' in col) return false;
|
|
395
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
396
|
+
return colDef ? colDef.suppressHeaderMenuButton !== true : true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
onHeaderMenuClick(event: MouseEvent, col: Column | ColDef<TData> | ColGroupDef<TData>): void {
|
|
400
|
+
event.stopPropagation();
|
|
401
|
+
|
|
402
|
+
if (this.activeHeaderMenu === col) {
|
|
403
|
+
this.closeHeaderMenu();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.activeHeaderMenu = col;
|
|
408
|
+
|
|
409
|
+
// Position menu below the icon using fixed (viewport) coordinates
|
|
410
|
+
const target = event.target as HTMLElement;
|
|
411
|
+
const rect = target.getBoundingClientRect();
|
|
412
|
+
|
|
413
|
+
let x = rect.right - 200; // Align right, assuming menu width ~200px
|
|
414
|
+
let y = rect.bottom + 4;
|
|
415
|
+
|
|
416
|
+
// Prevent menu from going off-screen
|
|
417
|
+
if (x < 0) x = 0;
|
|
418
|
+
if (x + 200 > window.innerWidth) x = window.innerWidth - 200;
|
|
419
|
+
if (y + 250 > window.innerHeight) { // Assuming max menu height ~250px
|
|
420
|
+
y = rect.top - 250; // Show above if overflows bottom
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
this.headerMenuPosition = { x, y };
|
|
424
|
+
|
|
425
|
+
this.cdr.detectChanges();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
closeHeaderMenu(): void {
|
|
429
|
+
this.activeHeaderMenu = null;
|
|
430
|
+
this.cdr.detectChanges();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
onContainerClick(event: MouseEvent): void {
|
|
434
|
+
if (this.activeHeaderMenu) {
|
|
435
|
+
this.closeHeaderMenu();
|
|
436
|
+
}
|
|
437
|
+
if (this.activeContextMenu) {
|
|
438
|
+
this.closeContextMenu();
|
|
439
|
+
}
|
|
440
|
+
// Handle closing editor on click outside
|
|
441
|
+
if (this.isEditing) {
|
|
442
|
+
const target = event.target as HTMLElement;
|
|
443
|
+
if (!target.closest('.argent-grid-cell-editor')) {
|
|
444
|
+
this.stopEditing(true);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
onCanvasContextMenu(event: MouseEvent): void {
|
|
450
|
+
event.preventDefault();
|
|
451
|
+
|
|
452
|
+
// Get hit test from canvas renderer to know which cell was clicked
|
|
453
|
+
const hitTest = this.canvasRenderer.getHitTestResult(event);
|
|
454
|
+
if (!hitTest || hitTest.rowIndex === -1) return;
|
|
455
|
+
|
|
456
|
+
const rowNode = this.gridApi.getDisplayedRowAtIndex(hitTest.rowIndex);
|
|
457
|
+
const columns = this.gridApi.getAllColumns().filter(col => col.visible);
|
|
458
|
+
const column = columns[hitTest.columnIndex];
|
|
459
|
+
|
|
460
|
+
if (!rowNode || !column) return;
|
|
461
|
+
|
|
462
|
+
this.contextMenuCell = { rowNode, column };
|
|
463
|
+
|
|
464
|
+
// Resolve menu items via API if provided
|
|
465
|
+
const getContextMenuItems = this.gridApi.getGridOption('getContextMenuItems');
|
|
466
|
+
if (getContextMenuItems) {
|
|
467
|
+
const params: GetContextMenuItemsParams<TData> = {
|
|
468
|
+
node: rowNode,
|
|
469
|
+
column: column,
|
|
470
|
+
api: this.gridApi,
|
|
471
|
+
type: 'cell',
|
|
472
|
+
event: event
|
|
473
|
+
};
|
|
474
|
+
this.contextMenuItems = this.resolveContextMenuItems(getContextMenuItems(params));
|
|
475
|
+
} else {
|
|
476
|
+
// Fallback to defaults if no callback provided
|
|
477
|
+
this.contextMenuItems = this.resolveContextMenuItems([
|
|
478
|
+
'copy', 'copyWithHeaders', 'separator', 'export', 'separator', 'resetColumns'
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (this.contextMenuItems.length === 0) return;
|
|
483
|
+
|
|
484
|
+
this.activeContextMenu = true;
|
|
485
|
+
|
|
486
|
+
// Position menu at mouse coordinates (fixed/viewport)
|
|
487
|
+
let x = event.clientX;
|
|
488
|
+
let y = event.clientY;
|
|
489
|
+
|
|
490
|
+
// Prevent menu from going off-screen
|
|
491
|
+
if (x + 200 > window.innerWidth) x = window.innerWidth - 200;
|
|
492
|
+
if (y + 200 > window.innerHeight) y = window.innerHeight - 200;
|
|
493
|
+
|
|
494
|
+
this.contextMenuPosition = { x, y };
|
|
495
|
+
|
|
496
|
+
// Select the row
|
|
497
|
+
this.gridApi.deselectAll();
|
|
498
|
+
rowNode.selected = true;
|
|
499
|
+
this.updateSelectionState();
|
|
500
|
+
this.canvasRenderer?.render();
|
|
501
|
+
this.selectionChanged.emit(this.gridApi.getSelectedRows());
|
|
502
|
+
|
|
503
|
+
this.cdr.detectChanges();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private resolveContextMenuItems(items: (DefaultMenuItem | MenuItemDef)[]): MenuItemDef[] {
|
|
507
|
+
const resolved: MenuItemDef[] = [];
|
|
508
|
+
|
|
509
|
+
items.forEach(item => {
|
|
510
|
+
if (typeof item === 'string') {
|
|
511
|
+
const defaultItem = this.getDefaultMenuItem(item);
|
|
512
|
+
if (defaultItem) resolved.push(defaultItem);
|
|
513
|
+
} else {
|
|
514
|
+
resolved.push(item);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return resolved;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private getDefaultMenuItem(key: DefaultMenuItem): MenuItemDef | null {
|
|
522
|
+
switch (key) {
|
|
523
|
+
case 'copy':
|
|
524
|
+
return { name: 'Copy Cell', action: () => this.copyContextMenuCell(), icon: '📋' };
|
|
525
|
+
case 'copyWithHeaders':
|
|
526
|
+
return this.hasRangeSelection() ?
|
|
527
|
+
{ name: 'Copy with Headers', action: () => this.copyRangeWithHeaders(), icon: '📋' } : null;
|
|
528
|
+
case 'export':
|
|
529
|
+
return {
|
|
530
|
+
name: 'Export',
|
|
531
|
+
action: () => {},
|
|
532
|
+
icon: '⤓',
|
|
533
|
+
subMenu: [
|
|
534
|
+
{ name: 'Export to CSV', action: () => this.exportCSV() },
|
|
535
|
+
{ name: 'Export to Excel (.xlsx)', action: () => this.exportExcel() }
|
|
536
|
+
]
|
|
537
|
+
};
|
|
538
|
+
case 'resetColumns':
|
|
539
|
+
return { name: 'Reset Columns', action: () => this.resetColumns(), icon: '⟲' };
|
|
540
|
+
case 'separator':
|
|
541
|
+
return { name: '', action: () => {}, separator: true };
|
|
542
|
+
default:
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
closeContextMenu(): void {
|
|
548
|
+
this.activeContextMenu = false;
|
|
549
|
+
this.contextMenuCell = null;
|
|
550
|
+
this.cdr.detectChanges();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Side Bar Methods
|
|
554
|
+
toggleToolPanel(panel: 'columns' | 'filters'): void {
|
|
555
|
+
if (this.activeToolPanel === panel) {
|
|
556
|
+
this.activeToolPanel = null;
|
|
557
|
+
} else {
|
|
558
|
+
this.activeToolPanel = panel;
|
|
559
|
+
}
|
|
560
|
+
this.cdr.detectChanges();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
toggleColumnVisibility(col: Column): void {
|
|
564
|
+
const colDef = this.getColumnDefForColumn(col);
|
|
565
|
+
if (colDef) {
|
|
566
|
+
colDef.hide = col.visible; // Toggle
|
|
567
|
+
this.initializeGrid(); // Re-initialize to handle visibility changes correctly
|
|
568
|
+
this.canvasRenderer?.render();
|
|
569
|
+
this.cdr.detectChanges();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
getAllColumns(): Column[] {
|
|
574
|
+
return this.gridApi?.getAllColumns() || [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
onSidebarColumnDropped(event: CdkDragDrop<Column[]>): void {
|
|
578
|
+
if (!this.columnDefs) return;
|
|
579
|
+
|
|
580
|
+
const columns = this.getAllColumns();
|
|
581
|
+
moveItemInArray(columns, event.previousIndex, event.currentIndex);
|
|
582
|
+
|
|
583
|
+
// Map back to ColDefs
|
|
584
|
+
const newDefs: (ColDef<TData> | ColGroupDef<TData>)[] = [];
|
|
585
|
+
columns.forEach(col => {
|
|
586
|
+
const def = this.getColumnDefForColumn(col);
|
|
587
|
+
if (def) newDefs.push(def);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
this.onColumnDefsChanged(newDefs);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
copyContextMenuCell(): void {
|
|
594
|
+
if (!this.contextMenuCell || !this.contextMenuCell.column.field) return;
|
|
595
|
+
|
|
596
|
+
const val = (this.contextMenuCell.rowNode.data as any)[this.contextMenuCell.column.field];
|
|
597
|
+
if (val !== undefined && val !== null) {
|
|
598
|
+
navigator.clipboard.writeText(String(val)).catch(err => {
|
|
599
|
+
console.error('Failed to copy text: ', err);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
this.closeContextMenu();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
hasRangeSelection(): boolean {
|
|
606
|
+
return (this.gridApi?.getCellRanges()?.length || 0) > 0;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
copyRangeWithHeaders(): void {
|
|
610
|
+
const ranges = this.gridApi?.getCellRanges();
|
|
611
|
+
if (!ranges || ranges.length === 0) return;
|
|
612
|
+
|
|
613
|
+
const range = ranges[0];
|
|
614
|
+
const columns = range.columns;
|
|
615
|
+
|
|
616
|
+
let text = columns.map(c => this.getHeaderName(c)).join('\t') + '\n';
|
|
617
|
+
|
|
618
|
+
for (let i = range.startRow; i <= range.endRow; i++) {
|
|
619
|
+
const node = this.gridApi.getDisplayedRowAtIndex(i);
|
|
620
|
+
if (node) {
|
|
621
|
+
text += columns.map(c => {
|
|
622
|
+
const val = (node.data as any)[c.field || ''];
|
|
623
|
+
return val !== null && val !== undefined ? String(val) : '';
|
|
624
|
+
}).join('\t') + '\n';
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
navigator.clipboard.writeText(text).catch(err => {
|
|
629
|
+
console.error('Failed to copy range: ', err);
|
|
630
|
+
});
|
|
631
|
+
this.closeContextMenu();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
exportCSV(): void {
|
|
635
|
+
this.gridApi.exportDataAsCsv();
|
|
636
|
+
this.closeContextMenu();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
exportExcel(): void {
|
|
640
|
+
this.gridApi.exportDataAsExcel();
|
|
641
|
+
this.closeContextMenu();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
resetColumns(): void {
|
|
645
|
+
if (this.initialColumnDefs) {
|
|
646
|
+
// Deep copy back the original defs
|
|
647
|
+
const restored = JSON.parse(JSON.stringify(this.initialColumnDefs));
|
|
648
|
+
this.onColumnDefsChanged(restored);
|
|
649
|
+
|
|
650
|
+
// Also clear sort model
|
|
651
|
+
this.gridApi.setSortModel([]);
|
|
652
|
+
}
|
|
653
|
+
this.closeContextMenu();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
sortColumnMenu(direction: 'asc' | 'desc' | null): void {
|
|
657
|
+
if (!this.activeHeaderMenu) return;
|
|
658
|
+
|
|
659
|
+
const col = this.activeHeaderMenu as any;
|
|
660
|
+
const colId = col.colId || col.field?.toString() || '';
|
|
661
|
+
|
|
662
|
+
// Update original ColDef to ensure persistence
|
|
663
|
+
const colDef = this.getColumnDefForColumn(col);
|
|
664
|
+
if (colDef) {
|
|
665
|
+
colDef.sort = direction;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
this.gridApi.setSortModel(direction ? [{ colId, sort: direction }] : []);
|
|
669
|
+
this.canvasRenderer?.render();
|
|
670
|
+
|
|
671
|
+
this.closeHeaderMenu();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
hideColumnMenu(): void {
|
|
675
|
+
if (!this.activeHeaderMenu) return;
|
|
676
|
+
|
|
677
|
+
const col = this.activeHeaderMenu as any;
|
|
678
|
+
|
|
679
|
+
// Update the original column definition
|
|
680
|
+
const colDef = this.getColumnDefForColumn(col);
|
|
681
|
+
if (colDef) {
|
|
682
|
+
colDef.hide = true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Create new array to trigger change detection and API update
|
|
686
|
+
if (this.columnDefs) {
|
|
687
|
+
this.onColumnDefsChanged([...this.columnDefs]);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.closeHeaderMenu();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
pinColumnMenu(pin: 'left' | 'right' | null): void {
|
|
694
|
+
if (!this.activeHeaderMenu) return;
|
|
695
|
+
|
|
696
|
+
const col = this.activeHeaderMenu as any;
|
|
697
|
+
|
|
698
|
+
// Update the original column definition
|
|
699
|
+
const colDef = this.getColumnDefForColumn(col);
|
|
700
|
+
if (colDef) {
|
|
701
|
+
colDef.pinned = pin as any;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (this.columnDefs) {
|
|
705
|
+
this.onColumnDefsChanged([...this.columnDefs]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
this.closeHeaderMenu();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
onColumnDropped(event: CdkDragDrop<any[]>, pinType: 'left' | 'right' | 'none'): void {
|
|
712
|
+
if (!this.columnDefs) return;
|
|
713
|
+
|
|
714
|
+
// Get current groups (using internal Columns)
|
|
715
|
+
const left = [...this.getLeftPinnedColumns()];
|
|
716
|
+
const center = [...this.getNonPinnedColumns()];
|
|
717
|
+
const right = [...this.getRightPinnedColumns()];
|
|
718
|
+
|
|
719
|
+
const containerMap: { [key: string]: any[] } = {
|
|
720
|
+
'left-pinned': left,
|
|
721
|
+
'scrollable': center,
|
|
722
|
+
'right-pinned': right
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const previousContainerData = containerMap[event.previousContainer.id];
|
|
726
|
+
const currentContainerData = containerMap[event.container.id];
|
|
727
|
+
|
|
728
|
+
if (event.previousContainer === event.container) {
|
|
729
|
+
moveItemInArray(currentContainerData, event.previousIndex, event.currentIndex);
|
|
730
|
+
} else {
|
|
731
|
+
transferArrayItem(
|
|
732
|
+
previousContainerData,
|
|
733
|
+
currentContainerData,
|
|
734
|
+
event.previousIndex,
|
|
735
|
+
event.currentIndex
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
// Update pinned state of the moved column in its original definition
|
|
739
|
+
const movedCol = currentContainerData[event.currentIndex] as Column;
|
|
740
|
+
const colDef = this.getColumnDefForColumn(movedCol);
|
|
741
|
+
if (colDef) {
|
|
742
|
+
colDef.pinned = pinType === 'none' ? null : pinType as any;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Map internal Columns back to their original ColDefs in the new order
|
|
747
|
+
const orderedVisibleColDefs: (ColDef<TData> | ColGroupDef<TData>)[] = [];
|
|
748
|
+
[...left, ...center, ...right].forEach(col => {
|
|
749
|
+
const def = this.getColumnDefForColumn(col);
|
|
750
|
+
if (def) orderedVisibleColDefs.push(def);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Reconstruct full columnDefs array, maintaining hidden columns
|
|
754
|
+
const hidden = this.columnDefs.filter(c => {
|
|
755
|
+
if ('children' in c) return false;
|
|
756
|
+
return (c as ColDef).hide;
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const newDefs = [...orderedVisibleColDefs, ...hidden];
|
|
760
|
+
|
|
761
|
+
this.onColumnDefsChanged(newDefs);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// --- Column Resizing Logic ---
|
|
765
|
+
|
|
766
|
+
isResizable(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
|
|
767
|
+
if ('children' in col) return false;
|
|
768
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
769
|
+
return colDef ? colDef.resizable !== false : true;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
onResizeMouseDown(event: MouseEvent, col: Column): void {
|
|
773
|
+
event.stopPropagation();
|
|
774
|
+
event.preventDefault();
|
|
775
|
+
|
|
776
|
+
this.isResizing = true;
|
|
777
|
+
this.resizeColumn = col;
|
|
778
|
+
this.resizeStartX = event.clientX;
|
|
779
|
+
this.resizeStartWidth = col.width;
|
|
780
|
+
|
|
781
|
+
const mouseMoveHandler = (e: MouseEvent) => this.onResizeMouseMove(e);
|
|
782
|
+
const mouseUpHandler = () => {
|
|
783
|
+
this.onResizeMouseUp();
|
|
784
|
+
window.removeEventListener('mousemove', mouseMoveHandler);
|
|
785
|
+
window.removeEventListener('mouseup', mouseUpHandler);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
window.addEventListener('mousemove', mouseMoveHandler);
|
|
789
|
+
window.addEventListener('mouseup', mouseUpHandler);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private onResizeMouseMove(event: MouseEvent): void {
|
|
793
|
+
if (!this.isResizing || !this.resizeColumn) return;
|
|
794
|
+
|
|
795
|
+
const deltaX = event.clientX - this.resizeStartX;
|
|
796
|
+
const newWidth = Math.max(20, this.resizeStartWidth + deltaX);
|
|
797
|
+
|
|
798
|
+
// Update internal column width
|
|
799
|
+
this.resizeColumn.width = newWidth;
|
|
800
|
+
|
|
801
|
+
// Update original ColDef
|
|
802
|
+
const colDef = this.getColumnDefForColumn(this.resizeColumn);
|
|
803
|
+
if (colDef) {
|
|
804
|
+
colDef.width = newWidth;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Force re-render
|
|
808
|
+
this.canvasRenderer?.render();
|
|
809
|
+
this.cdr.detectChanges();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private onResizeMouseUp(): void {
|
|
813
|
+
this.isResizing = false;
|
|
814
|
+
this.resizeColumn = null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// --- Floating Filter Logic ---
|
|
818
|
+
|
|
819
|
+
hasFloatingFilters(): boolean {
|
|
820
|
+
if (this.gridApi?.getGridOption('floatingFilter')) return true;
|
|
821
|
+
|
|
822
|
+
if (!this.columnDefs) return false;
|
|
823
|
+
return this.columnDefs.some(col => {
|
|
824
|
+
if ('children' in col) {
|
|
825
|
+
return col.children.some(child => 'floatingFilter' in child && child.floatingFilter);
|
|
826
|
+
}
|
|
827
|
+
return col.floatingFilter;
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
isFloatingFilterEnabled(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
|
|
832
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
833
|
+
if (!colDef || 'children' in colDef) return false;
|
|
834
|
+
if (!colDef.filter) return false;
|
|
835
|
+
|
|
836
|
+
if (colDef.floatingFilter === true) return true;
|
|
837
|
+
if (colDef.floatingFilter === false) return false;
|
|
838
|
+
|
|
839
|
+
return !!this.gridApi?.getGridOption('floatingFilter');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
isFilterable(col: Column | ColDef<TData> | ColGroupDef<TData>): boolean {
|
|
843
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
844
|
+
if (!colDef || 'children' in colDef) return false;
|
|
845
|
+
return !!colDef.filter;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
getFilterInputType(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
|
|
849
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
850
|
+
if (!colDef || 'children' in colDef) return 'text';
|
|
851
|
+
const filter = colDef.filter;
|
|
852
|
+
if (filter === 'number') return 'number';
|
|
853
|
+
if (filter === 'date') return 'date';
|
|
854
|
+
return 'text';
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private filterTimeout: any;
|
|
858
|
+
onFloatingFilterInput(event: Event, col: Column | ColDef<TData> | ColGroupDef<TData>): void {
|
|
859
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
860
|
+
if (!colDef || 'children' in colDef) return;
|
|
861
|
+
|
|
862
|
+
const input = event.target as HTMLInputElement;
|
|
863
|
+
const value = input.value;
|
|
864
|
+
const colId = (col as any).colId || (col as any).field?.toString() || '';
|
|
865
|
+
|
|
866
|
+
this.cdr.detectChanges(); // Update clear button visibility immediately
|
|
867
|
+
|
|
868
|
+
clearTimeout(this.filterTimeout);
|
|
869
|
+
this.filterTimeout = setTimeout(() => {
|
|
870
|
+
const currentModel = this.gridApi.getFilterModel();
|
|
871
|
+
|
|
872
|
+
if (!value) {
|
|
873
|
+
delete currentModel[colId];
|
|
874
|
+
} else {
|
|
875
|
+
const filterType = this.getFilterTypeFromCol(colDef);
|
|
876
|
+
currentModel[colId] = {
|
|
877
|
+
filterType: filterType as any,
|
|
878
|
+
type: filterType === 'text' ? 'contains' : 'equals',
|
|
879
|
+
filter: value
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
this.gridApi.setFilterModel(currentModel);
|
|
884
|
+
this.canvasRenderer?.render();
|
|
885
|
+
}, 300);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private getFilterTypeFromCol(col: ColDef<TData>): string {
|
|
889
|
+
const filter = col.filter;
|
|
890
|
+
if (filter === 'number') return 'number';
|
|
891
|
+
if (filter === 'date') return 'date';
|
|
892
|
+
if (filter === 'boolean') return 'boolean';
|
|
893
|
+
return 'text';
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
getFloatingFilterValue(col: Column | ColDef<TData> | ColGroupDef<TData>): string {
|
|
897
|
+
if (!this.gridApi) return '';
|
|
898
|
+
const colId = (col as any).colId || (col as any).field?.toString() || '';
|
|
899
|
+
const model = this.gridApi.getFilterModel();
|
|
900
|
+
return model[colId]?.filter || '';
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
hasFilterValue(col: Column | ColDef<TData> | ColGroupDef<TData>, input: HTMLInputElement): boolean {
|
|
904
|
+
return !!this.getFloatingFilterValue(col);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
clearFloatingFilter(col: Column | ColDef<TData> | ColGroupDef<TData>, input: HTMLInputElement): void {
|
|
908
|
+
const colDef = this.getColumnDefForColumn(col as any);
|
|
909
|
+
if (!colDef || 'children' in colDef) return;
|
|
910
|
+
|
|
911
|
+
input.value = '';
|
|
912
|
+
const colId = (col as any).colId || (col as any).field?.toString() || '';
|
|
913
|
+
|
|
914
|
+
const currentModel = this.gridApi.getFilterModel();
|
|
915
|
+
delete currentModel[colId];
|
|
916
|
+
|
|
917
|
+
this.gridApi.setFilterModel(currentModel);
|
|
918
|
+
this.canvasRenderer?.render();
|
|
919
|
+
this.cdr.detectChanges();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Public API methods
|
|
923
|
+
getApi(): GridApi<TData> {
|
|
924
|
+
return this.gridApi;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
refresh(): void {
|
|
928
|
+
this.canvasRenderer?.render();
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
getLastFrameTime(): number {
|
|
932
|
+
return this.canvasRenderer?.lastFrameTime || 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Cell Editing Methods
|
|
936
|
+
startEditing(rowIndex: number, colId: string): void {
|
|
937
|
+
const rowNode = this.gridApi.getDisplayedRowAtIndex(rowIndex);
|
|
938
|
+
const column = this.gridApi.getColumn(colId);
|
|
939
|
+
|
|
940
|
+
// Prevent editing on group rows or missing data/column
|
|
941
|
+
if (!rowNode || rowNode.group || !column || !column.field) return;
|
|
942
|
+
|
|
943
|
+
// Check if cell is editable
|
|
944
|
+
const colDef = this.getColumnDefForColumn(column);
|
|
945
|
+
if (colDef && colDef.editable === false) return;
|
|
946
|
+
|
|
947
|
+
// If already editing another cell, stop it first
|
|
948
|
+
if (this.isEditing) {
|
|
949
|
+
this.stopEditing(true);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const value = (rowNode.data as any)[column.field];
|
|
953
|
+
|
|
954
|
+
this.editingRowNode = rowNode;
|
|
955
|
+
this.editingColDef = colDef;
|
|
956
|
+
this.editingValue = value !== null && value !== undefined ? String(value) : '';
|
|
957
|
+
|
|
958
|
+
// Calculate editor position based on row and column
|
|
959
|
+
const columns = this.gridApi.getAllColumns().filter(c => c.visible);
|
|
960
|
+
let x = 0;
|
|
961
|
+
for (const col of columns) {
|
|
962
|
+
if (col.colId === colId) break;
|
|
963
|
+
x += col.width;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
this.editorPosition = {
|
|
967
|
+
x: x - this.canvasRenderer.currentScrollLeft,
|
|
968
|
+
y: (rowIndex * this.rowHeight) - this.canvasRenderer.currentScrollTop,
|
|
969
|
+
width: column.width,
|
|
970
|
+
height: this.rowHeight
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
this.isEditing = true;
|
|
974
|
+
|
|
975
|
+
// Focus input after view update
|
|
976
|
+
setTimeout(() => {
|
|
977
|
+
if (this.editorInputRef) {
|
|
978
|
+
const input = this.editorInputRef.nativeElement;
|
|
979
|
+
input.focus();
|
|
980
|
+
input.select();
|
|
981
|
+
}
|
|
982
|
+
}, 0);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
stopEditing(save: boolean = true): void {
|
|
986
|
+
if (!this.isEditing) return;
|
|
987
|
+
|
|
988
|
+
const rowNode = this.editingRowNode;
|
|
989
|
+
const colDef = this.editingColDef;
|
|
990
|
+
|
|
991
|
+
// Capture current value from input directly if it exists, to be sure
|
|
992
|
+
if (this.editorInputRef) {
|
|
993
|
+
this.editingValue = this.editorInputRef.nativeElement.value;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (save && colDef && rowNode) {
|
|
997
|
+
const newValue = this.editingValue;
|
|
998
|
+
const field = colDef.field as string;
|
|
999
|
+
const oldValue = (rowNode.data as any)[field];
|
|
1000
|
+
|
|
1001
|
+
// Apply valueParser if provided
|
|
1002
|
+
let parsedValue: any = newValue;
|
|
1003
|
+
if (typeof colDef.valueParser === 'function') {
|
|
1004
|
+
parsedValue = colDef.valueParser({
|
|
1005
|
+
value: rowNode.data,
|
|
1006
|
+
newValue,
|
|
1007
|
+
data: rowNode.data,
|
|
1008
|
+
node: rowNode,
|
|
1009
|
+
colDef,
|
|
1010
|
+
api: this.gridApi
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Apply valueSetter if provided
|
|
1015
|
+
if (typeof colDef.valueSetter === 'function') {
|
|
1016
|
+
colDef.valueSetter({
|
|
1017
|
+
value: parsedValue,
|
|
1018
|
+
newValue: parsedValue,
|
|
1019
|
+
data: rowNode.data,
|
|
1020
|
+
node: rowNode,
|
|
1021
|
+
colDef,
|
|
1022
|
+
api: this.gridApi
|
|
1023
|
+
});
|
|
1024
|
+
} else if (field) {
|
|
1025
|
+
// Default: update data directly
|
|
1026
|
+
(rowNode.data as any)[field] = parsedValue;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Update via transaction
|
|
1030
|
+
this.gridApi.applyTransaction({
|
|
1031
|
+
update: [rowNode.data]
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
// Trigger callback
|
|
1035
|
+
if (colDef.onCellValueChanged) {
|
|
1036
|
+
const column = this.gridApi.getColumn(colDef.colId || field || '');
|
|
1037
|
+
if (column) {
|
|
1038
|
+
colDef.onCellValueChanged({
|
|
1039
|
+
newValue: parsedValue,
|
|
1040
|
+
oldValue,
|
|
1041
|
+
data: rowNode.data,
|
|
1042
|
+
node: rowNode,
|
|
1043
|
+
column
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
this.canvasRenderer?.render();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
this.isEditing = false;
|
|
1052
|
+
this.editingRowNode = null;
|
|
1053
|
+
this.editingColDef = null;
|
|
1054
|
+
this.cdr.detectChanges();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
onEditorInput(event: Event): void {
|
|
1058
|
+
const input = event.target as HTMLInputElement;
|
|
1059
|
+
this.editingValue = input.value;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
onEditorKeydown(event: KeyboardEvent): void {
|
|
1063
|
+
if (event.key === 'Enter') {
|
|
1064
|
+
event.preventDefault();
|
|
1065
|
+
this.stopEditing(true);
|
|
1066
|
+
} else if (event.key === 'Escape') {
|
|
1067
|
+
event.preventDefault();
|
|
1068
|
+
this.stopEditing(false);
|
|
1069
|
+
} else if (event.key === 'Tab') {
|
|
1070
|
+
event.preventDefault();
|
|
1071
|
+
const currentRowIndex = this.editingRowNode?.displayedRowIndex ?? -1;
|
|
1072
|
+
const currentColId = this.editingColDef?.colId || this.editingColDef?.field?.toString() || '';
|
|
1073
|
+
|
|
1074
|
+
this.stopEditing(true);
|
|
1075
|
+
|
|
1076
|
+
// Standard AG Grid Tab behavior: move to next cell
|
|
1077
|
+
if (currentRowIndex !== -1) {
|
|
1078
|
+
this.moveToNextCell(currentRowIndex, currentColId, event.shiftKey);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private moveToNextCell(rowIndex: number, colId: string, backwards: boolean): void {
|
|
1084
|
+
const columns = this.gridApi.getAllColumns().filter(c => c.visible);
|
|
1085
|
+
const colIndex = columns.findIndex(c => c.colId === colId);
|
|
1086
|
+
|
|
1087
|
+
if (colIndex === -1) return;
|
|
1088
|
+
|
|
1089
|
+
let nextColIndex = backwards ? colIndex - 1 : colIndex + 1;
|
|
1090
|
+
let nextRowIndex = rowIndex;
|
|
1091
|
+
|
|
1092
|
+
if (nextColIndex >= columns.length) {
|
|
1093
|
+
nextColIndex = 0;
|
|
1094
|
+
nextRowIndex++;
|
|
1095
|
+
} else if (nextColIndex < 0) {
|
|
1096
|
+
nextColIndex = columns.length - 1;
|
|
1097
|
+
nextRowIndex--;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (nextRowIndex >= 0 && nextRowIndex < this.gridApi.getDisplayedRowCount()) {
|
|
1101
|
+
const nextCol = columns[nextColIndex];
|
|
1102
|
+
this.startEditing(nextRowIndex, nextCol.colId);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
onEditorBlur(): void {
|
|
1107
|
+
// Save on blur, matching AG Grid default behavior
|
|
1108
|
+
if (this.isEditing) {
|
|
1109
|
+
this.stopEditing(true);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
private getColumnDefForColumn(column: Column | ColDef<TData> | ColGroupDef<TData>): ColDef<TData> | null {
|
|
1113
|
+
if (!this.columnDefs) return null;
|
|
1114
|
+
|
|
1115
|
+
const colId = (column as any).colId || (column as any).field?.toString();
|
|
1116
|
+
if (!colId) return null;
|
|
1117
|
+
|
|
1118
|
+
for (const def of this.columnDefs) {
|
|
1119
|
+
if ('children' in def) {
|
|
1120
|
+
const found = def.children.find(c => {
|
|
1121
|
+
const cDef = c as ColDef;
|
|
1122
|
+
return cDef.colId === colId || cDef.field?.toString() === colId;
|
|
1123
|
+
});
|
|
1124
|
+
if (found) return found as ColDef<TData>;
|
|
1125
|
+
} else {
|
|
1126
|
+
const cDef = def as ColDef;
|
|
1127
|
+
if (cDef.colId === colId || cDef.field?.toString() === colId) {
|
|
1128
|
+
return def as ColDef<TData>;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Selection Methods
|
|
1136
|
+
onRowClick(rowIndex: number, event: MouseEvent): void {
|
|
1137
|
+
const rowNode = this.gridApi.getDisplayedRowAtIndex(rowIndex);
|
|
1138
|
+
if (!rowNode) return;
|
|
1139
|
+
|
|
1140
|
+
// Handle multi-select with Ctrl/Cmd
|
|
1141
|
+
if (event.ctrlKey || event.metaKey) {
|
|
1142
|
+
rowNode.selected = !rowNode.selected;
|
|
1143
|
+
} else if (event.shiftKey) {
|
|
1144
|
+
// Range selection (TODO: implement)
|
|
1145
|
+
rowNode.selected = true;
|
|
1146
|
+
} else {
|
|
1147
|
+
// Single select - deselect all others
|
|
1148
|
+
this.gridApi.deselectAll();
|
|
1149
|
+
rowNode.selected = true;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
this.updateSelectionState();
|
|
1153
|
+
this.canvasRenderer?.render();
|
|
1154
|
+
this.selectionChanged.emit(this.gridApi.getSelectedRows());
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
onSelectionHeaderClick(): void {
|
|
1158
|
+
// Toggle all
|
|
1159
|
+
if (this.isAllSelected) {
|
|
1160
|
+
this.gridApi.deselectAll();
|
|
1161
|
+
} else {
|
|
1162
|
+
this.gridApi.selectAll();
|
|
1163
|
+
}
|
|
1164
|
+
this.updateSelectionState();
|
|
1165
|
+
this.canvasRenderer?.render();
|
|
1166
|
+
this.selectionChanged.emit(this.gridApi.getSelectedRows());
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
onSelectionHeaderChange(event: Event): void {
|
|
1170
|
+
const checkbox = event.target as HTMLInputElement;
|
|
1171
|
+
if (checkbox.checked) {
|
|
1172
|
+
this.gridApi.selectAll();
|
|
1173
|
+
} else {
|
|
1174
|
+
this.gridApi.deselectAll();
|
|
1175
|
+
}
|
|
1176
|
+
this.updateSelectionState();
|
|
1177
|
+
this.canvasRenderer?.render();
|
|
1178
|
+
this.selectionChanged.emit(this.gridApi.getSelectedRows());
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
updateSelectionState(): void {
|
|
1182
|
+
const selectedCount = this.gridApi.getSelectedRows().length;
|
|
1183
|
+
const totalCount = this.gridApi.getDisplayedRowCount();
|
|
1184
|
+
|
|
1185
|
+
this.isAllSelected = selectedCount === totalCount && totalCount > 0;
|
|
1186
|
+
this.isIndeterminateSelection = selectedCount > 0 && selectedCount < totalCount;
|
|
1187
|
+
}
|
|
1188
|
+
}
|