gp-grid-react 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/dist/index.d.ts +211 -0
- package/dist/index.js +1169 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/Grid.tsx +1220 -0
- package/src/SlotRow.tsx +258 -0
- package/src/index.ts +47 -0
- package/src/styles.ts +436 -0
- package/tsconfig.json +29 -0
- package/tsdown.config.ts +11 -0
- package/vitest.config.ts +16 -0
package/src/Grid.tsx
ADDED
|
@@ -0,0 +1,1220 @@
|
|
|
1
|
+
// packages/react/src/Grid.tsx
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useReducer,
|
|
7
|
+
useCallback,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
GridCore,
|
|
13
|
+
createClientDataSource,
|
|
14
|
+
createDataSourceFromArray,
|
|
15
|
+
} from "gp-grid-core";
|
|
16
|
+
import type {
|
|
17
|
+
GridInstruction,
|
|
18
|
+
ColumnDefinition,
|
|
19
|
+
DataSource,
|
|
20
|
+
Row,
|
|
21
|
+
CellRendererParams,
|
|
22
|
+
EditRendererParams,
|
|
23
|
+
HeaderRendererParams,
|
|
24
|
+
CellPosition,
|
|
25
|
+
CellRange,
|
|
26
|
+
CellValue,
|
|
27
|
+
SortDirection,
|
|
28
|
+
} from "gp-grid-core";
|
|
29
|
+
import { injectStyles } from "./styles";
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Types
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
export type ReactCellRenderer = (params: CellRendererParams) => React.ReactNode;
|
|
36
|
+
export type ReactEditRenderer = (params: EditRendererParams) => React.ReactNode;
|
|
37
|
+
export type ReactHeaderRenderer = (params: HeaderRendererParams) => React.ReactNode;
|
|
38
|
+
|
|
39
|
+
export interface GridProps<TData extends Row = Row> {
|
|
40
|
+
columns: ColumnDefinition[];
|
|
41
|
+
/** Data source for the grid */
|
|
42
|
+
dataSource?: DataSource<TData>;
|
|
43
|
+
/** Legacy: Raw row data (will be wrapped in a client data source) */
|
|
44
|
+
rowData?: TData[];
|
|
45
|
+
rowHeight: number;
|
|
46
|
+
headerHeight?: number;
|
|
47
|
+
overscan?: number;
|
|
48
|
+
/** Show filter row below headers */
|
|
49
|
+
showFilters?: boolean;
|
|
50
|
+
/** Debounce time for filter input (ms) */
|
|
51
|
+
filterDebounce?: number;
|
|
52
|
+
/** Enable dark mode styling */
|
|
53
|
+
darkMode?: boolean;
|
|
54
|
+
|
|
55
|
+
// Renderer registries
|
|
56
|
+
cellRenderers?: Record<string, ReactCellRenderer>;
|
|
57
|
+
editRenderers?: Record<string, ReactEditRenderer>;
|
|
58
|
+
headerRenderers?: Record<string, ReactHeaderRenderer>;
|
|
59
|
+
|
|
60
|
+
// Global fallback renderers
|
|
61
|
+
cellRenderer?: ReactCellRenderer;
|
|
62
|
+
editRenderer?: ReactEditRenderer;
|
|
63
|
+
headerRenderer?: ReactHeaderRenderer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// State Types
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
interface SlotData {
|
|
71
|
+
slotId: string;
|
|
72
|
+
rowIndex: number;
|
|
73
|
+
rowData: Row;
|
|
74
|
+
translateY: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface GridState {
|
|
78
|
+
slots: Map<string, SlotData>;
|
|
79
|
+
activeCell: CellPosition | null;
|
|
80
|
+
selectionRange: CellRange | null;
|
|
81
|
+
editingCell: { row: number; col: number; initialValue: CellValue } | null;
|
|
82
|
+
contentWidth: number;
|
|
83
|
+
contentHeight: number;
|
|
84
|
+
headers: Map<number, { column: ColumnDefinition; sortDirection?: SortDirection; sortIndex?: number }>;
|
|
85
|
+
isLoading: boolean;
|
|
86
|
+
error: string | null;
|
|
87
|
+
totalRows: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type GridAction =
|
|
91
|
+
| { type: "BATCH_INSTRUCTIONS"; instructions: GridInstruction[] }
|
|
92
|
+
| { type: "RESET" };
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// Reducer
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply a single instruction to mutable slot maps and return other state changes.
|
|
100
|
+
* This allows batching multiple slot operations efficiently.
|
|
101
|
+
*/
|
|
102
|
+
function applyInstruction(
|
|
103
|
+
instruction: GridInstruction,
|
|
104
|
+
slots: Map<string, SlotData>,
|
|
105
|
+
headers: Map<number, { column: ColumnDefinition; sortDirection?: SortDirection; sortIndex?: number }>
|
|
106
|
+
): Partial<GridState> | null {
|
|
107
|
+
switch (instruction.type) {
|
|
108
|
+
case "CREATE_SLOT":
|
|
109
|
+
slots.set(instruction.slotId, {
|
|
110
|
+
slotId: instruction.slotId,
|
|
111
|
+
rowIndex: -1,
|
|
112
|
+
rowData: {},
|
|
113
|
+
translateY: 0,
|
|
114
|
+
});
|
|
115
|
+
return null; // Slots map is mutated
|
|
116
|
+
|
|
117
|
+
case "DESTROY_SLOT":
|
|
118
|
+
slots.delete(instruction.slotId);
|
|
119
|
+
return null;
|
|
120
|
+
|
|
121
|
+
case "ASSIGN_SLOT": {
|
|
122
|
+
const existing = slots.get(instruction.slotId);
|
|
123
|
+
if (existing) {
|
|
124
|
+
slots.set(instruction.slotId, {
|
|
125
|
+
...existing,
|
|
126
|
+
rowIndex: instruction.rowIndex,
|
|
127
|
+
rowData: instruction.rowData,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case "MOVE_SLOT": {
|
|
134
|
+
const existing = slots.get(instruction.slotId);
|
|
135
|
+
if (existing) {
|
|
136
|
+
slots.set(instruction.slotId, {
|
|
137
|
+
...existing,
|
|
138
|
+
translateY: instruction.translateY,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "SET_ACTIVE_CELL":
|
|
145
|
+
return { activeCell: instruction.position };
|
|
146
|
+
|
|
147
|
+
case "SET_SELECTION_RANGE":
|
|
148
|
+
return { selectionRange: instruction.range };
|
|
149
|
+
|
|
150
|
+
case "START_EDIT":
|
|
151
|
+
return {
|
|
152
|
+
editingCell: {
|
|
153
|
+
row: instruction.row,
|
|
154
|
+
col: instruction.col,
|
|
155
|
+
initialValue: instruction.initialValue,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
case "STOP_EDIT":
|
|
160
|
+
return { editingCell: null };
|
|
161
|
+
|
|
162
|
+
case "SET_CONTENT_SIZE":
|
|
163
|
+
return {
|
|
164
|
+
contentWidth: instruction.width,
|
|
165
|
+
contentHeight: instruction.height,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
case "UPDATE_HEADER":
|
|
169
|
+
headers.set(instruction.colIndex, {
|
|
170
|
+
column: instruction.column,
|
|
171
|
+
sortDirection: instruction.sortDirection,
|
|
172
|
+
sortIndex: instruction.sortIndex,
|
|
173
|
+
});
|
|
174
|
+
return null;
|
|
175
|
+
|
|
176
|
+
case "DATA_LOADING":
|
|
177
|
+
return { isLoading: true, error: null };
|
|
178
|
+
|
|
179
|
+
case "DATA_LOADED":
|
|
180
|
+
return { isLoading: false, totalRows: instruction.totalRows };
|
|
181
|
+
|
|
182
|
+
case "DATA_ERROR":
|
|
183
|
+
return { isLoading: false, error: instruction.error };
|
|
184
|
+
|
|
185
|
+
default:
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function gridReducer(state: GridState, action: GridAction): GridState {
|
|
191
|
+
if (action.type === "RESET") {
|
|
192
|
+
return createInitialState();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Process batch of instructions in one state update
|
|
196
|
+
const { instructions } = action;
|
|
197
|
+
// console.log("[GP-Grid Reducer] Processing batch:", instructions.map(i => i.type));
|
|
198
|
+
if (instructions.length === 0) {
|
|
199
|
+
return state;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Create mutable copies of Maps to batch updates
|
|
203
|
+
const newSlots = new Map(state.slots);
|
|
204
|
+
const newHeaders = new Map(state.headers);
|
|
205
|
+
let stateChanges: Partial<GridState> = {};
|
|
206
|
+
|
|
207
|
+
// Apply all instructions
|
|
208
|
+
for (const instruction of instructions) {
|
|
209
|
+
const changes = applyInstruction(instruction, newSlots, newHeaders);
|
|
210
|
+
if (changes) {
|
|
211
|
+
stateChanges = { ...stateChanges, ...changes };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Return new state with all changes applied
|
|
216
|
+
return {
|
|
217
|
+
...state,
|
|
218
|
+
...stateChanges,
|
|
219
|
+
slots: newSlots,
|
|
220
|
+
headers: newHeaders,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createInitialState(): GridState {
|
|
225
|
+
return {
|
|
226
|
+
slots: new Map(),
|
|
227
|
+
activeCell: null,
|
|
228
|
+
selectionRange: null,
|
|
229
|
+
editingCell: null,
|
|
230
|
+
contentWidth: 0,
|
|
231
|
+
contentHeight: 0,
|
|
232
|
+
headers: new Map(),
|
|
233
|
+
isLoading: false,
|
|
234
|
+
error: null,
|
|
235
|
+
totalRows: 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// =============================================================================
|
|
240
|
+
// Grid Component
|
|
241
|
+
// =============================================================================
|
|
242
|
+
|
|
243
|
+
export function Grid<TData extends Row = Row>(props: GridProps<TData>) {
|
|
244
|
+
// Inject styles on first render (safe to call multiple times)
|
|
245
|
+
injectStyles();
|
|
246
|
+
|
|
247
|
+
const {
|
|
248
|
+
columns,
|
|
249
|
+
dataSource: providedDataSource,
|
|
250
|
+
rowData,
|
|
251
|
+
rowHeight,
|
|
252
|
+
headerHeight = rowHeight,
|
|
253
|
+
overscan = 3,
|
|
254
|
+
showFilters = false,
|
|
255
|
+
filterDebounce = 300,
|
|
256
|
+
darkMode = false,
|
|
257
|
+
cellRenderers = {},
|
|
258
|
+
editRenderers = {},
|
|
259
|
+
headerRenderers = {},
|
|
260
|
+
cellRenderer,
|
|
261
|
+
editRenderer,
|
|
262
|
+
headerRenderer,
|
|
263
|
+
} = props;
|
|
264
|
+
|
|
265
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
266
|
+
const coreRef = useRef<GridCore<TData> | null>(null);
|
|
267
|
+
const [state, dispatch] = useReducer(gridReducer, null, createInitialState);
|
|
268
|
+
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
|
269
|
+
const filterTimeoutRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
270
|
+
const [isDraggingFill, setIsDraggingFill] = useState(false);
|
|
271
|
+
const [fillTarget, setFillTarget] = useState<{ row: number; col: number } | null>(null);
|
|
272
|
+
const [fillSourceRange, setFillSourceRange] = useState<{ startRow: number; startCol: number; endRow: number; endCol: number } | null>(null);
|
|
273
|
+
const autoScrollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
274
|
+
|
|
275
|
+
// Computed heights
|
|
276
|
+
const filterRowHeight = showFilters ? 40 : 0;
|
|
277
|
+
const totalHeaderHeight = headerHeight + filterRowHeight;
|
|
278
|
+
|
|
279
|
+
// Create data source from rowData if not provided
|
|
280
|
+
const dataSource = useMemo(() => {
|
|
281
|
+
if (providedDataSource) {
|
|
282
|
+
return providedDataSource;
|
|
283
|
+
}
|
|
284
|
+
if (rowData) {
|
|
285
|
+
return createDataSourceFromArray(rowData);
|
|
286
|
+
}
|
|
287
|
+
// Empty data source
|
|
288
|
+
return createClientDataSource<TData>([]);
|
|
289
|
+
}, [providedDataSource, rowData]);
|
|
290
|
+
|
|
291
|
+
// Compute column positions
|
|
292
|
+
const columnPositions = useMemo(() => {
|
|
293
|
+
const positions = [0];
|
|
294
|
+
let pos = 0;
|
|
295
|
+
for (const col of columns) {
|
|
296
|
+
pos += col.width;
|
|
297
|
+
positions.push(pos);
|
|
298
|
+
}
|
|
299
|
+
return positions;
|
|
300
|
+
}, [columns]);
|
|
301
|
+
|
|
302
|
+
const totalWidth = columnPositions[columnPositions.length - 1] ?? 0;
|
|
303
|
+
|
|
304
|
+
// Initialize GridCore
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
const core = new GridCore<TData>({
|
|
307
|
+
columns,
|
|
308
|
+
dataSource,
|
|
309
|
+
rowHeight,
|
|
310
|
+
headerHeight: totalHeaderHeight,
|
|
311
|
+
overscan,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
coreRef.current = core;
|
|
315
|
+
|
|
316
|
+
// Subscribe to batched instructions for efficient state updates
|
|
317
|
+
const unsubscribe = core.onBatchInstruction((instructions) => {
|
|
318
|
+
dispatch({ type: "BATCH_INSTRUCTIONS", instructions });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Initialize
|
|
322
|
+
core.initialize();
|
|
323
|
+
|
|
324
|
+
return () => {
|
|
325
|
+
unsubscribe();
|
|
326
|
+
coreRef.current = null;
|
|
327
|
+
};
|
|
328
|
+
}, [columns, dataSource, rowHeight, totalHeaderHeight, overscan]);
|
|
329
|
+
|
|
330
|
+
// Handle scroll
|
|
331
|
+
const handleScroll = useCallback(() => {
|
|
332
|
+
const container = containerRef.current;
|
|
333
|
+
const core = coreRef.current;
|
|
334
|
+
if (!container || !core) return;
|
|
335
|
+
|
|
336
|
+
core.setViewport(
|
|
337
|
+
container.scrollTop,
|
|
338
|
+
container.scrollLeft,
|
|
339
|
+
container.clientWidth,
|
|
340
|
+
container.clientHeight
|
|
341
|
+
);
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
// Initial measurement
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
const container = containerRef.current;
|
|
347
|
+
const core = coreRef.current;
|
|
348
|
+
if (!container || !core) return;
|
|
349
|
+
|
|
350
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
351
|
+
core.setViewport(
|
|
352
|
+
container.scrollTop,
|
|
353
|
+
container.scrollLeft,
|
|
354
|
+
container.clientWidth,
|
|
355
|
+
container.clientHeight
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
resizeObserver.observe(container);
|
|
360
|
+
handleScroll();
|
|
361
|
+
|
|
362
|
+
return () => resizeObserver.disconnect();
|
|
363
|
+
}, [handleScroll]);
|
|
364
|
+
|
|
365
|
+
// Handle filter change with debounce
|
|
366
|
+
const handleFilterChange = useCallback(
|
|
367
|
+
(colId: string, value: string) => {
|
|
368
|
+
setFilterValues((prev) => ({ ...prev, [colId]: value }));
|
|
369
|
+
|
|
370
|
+
// Clear existing timeout
|
|
371
|
+
if (filterTimeoutRef.current[colId]) {
|
|
372
|
+
clearTimeout(filterTimeoutRef.current[colId]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Debounce the actual filter application
|
|
376
|
+
filterTimeoutRef.current[colId] = setTimeout(() => {
|
|
377
|
+
const core = coreRef.current;
|
|
378
|
+
if (core) {
|
|
379
|
+
core.setFilter(colId, value);
|
|
380
|
+
}
|
|
381
|
+
}, filterDebounce);
|
|
382
|
+
},
|
|
383
|
+
[filterDebounce]
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
// Keyboard navigation
|
|
387
|
+
const handleKeyDown = useCallback(
|
|
388
|
+
(e: React.KeyboardEvent) => {
|
|
389
|
+
const core = coreRef.current;
|
|
390
|
+
if (!core) return;
|
|
391
|
+
|
|
392
|
+
// Don't handle keyboard events when editing
|
|
393
|
+
if (state.editingCell && e.key !== "Enter" && e.key !== "Escape" && e.key !== "Tab") {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const { selection } = core;
|
|
398
|
+
const isShift = e.shiftKey;
|
|
399
|
+
const isCtrl = e.ctrlKey || e.metaKey;
|
|
400
|
+
|
|
401
|
+
switch (e.key) {
|
|
402
|
+
case "ArrowUp":
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
selection.moveFocus("up", isShift);
|
|
405
|
+
break;
|
|
406
|
+
case "ArrowDown":
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
selection.moveFocus("down", isShift);
|
|
409
|
+
break;
|
|
410
|
+
case "ArrowLeft":
|
|
411
|
+
e.preventDefault();
|
|
412
|
+
selection.moveFocus("left", isShift);
|
|
413
|
+
break;
|
|
414
|
+
case "ArrowRight":
|
|
415
|
+
e.preventDefault();
|
|
416
|
+
selection.moveFocus("right", isShift);
|
|
417
|
+
break;
|
|
418
|
+
case "Enter":
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
if (state.editingCell) {
|
|
421
|
+
core.commitEdit();
|
|
422
|
+
} else if (state.activeCell) {
|
|
423
|
+
core.startEdit(state.activeCell.row, state.activeCell.col);
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
case "Escape":
|
|
427
|
+
e.preventDefault();
|
|
428
|
+
if (state.editingCell) {
|
|
429
|
+
core.cancelEdit();
|
|
430
|
+
} else {
|
|
431
|
+
selection.clearSelection();
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
case "Tab":
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
if (state.editingCell) {
|
|
437
|
+
core.commitEdit();
|
|
438
|
+
}
|
|
439
|
+
selection.moveFocus(isShift ? "left" : "right", false);
|
|
440
|
+
break;
|
|
441
|
+
case "a":
|
|
442
|
+
if (isCtrl) {
|
|
443
|
+
e.preventDefault();
|
|
444
|
+
selection.selectAll();
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
case "c":
|
|
448
|
+
if (isCtrl) {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
selection.copySelectionToClipboard();
|
|
451
|
+
}
|
|
452
|
+
break;
|
|
453
|
+
case "F2":
|
|
454
|
+
e.preventDefault();
|
|
455
|
+
if (state.activeCell && !state.editingCell) {
|
|
456
|
+
core.startEdit(state.activeCell.row, state.activeCell.col);
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
case "Delete":
|
|
460
|
+
case "Backspace":
|
|
461
|
+
// Start editing with empty value on delete/backspace
|
|
462
|
+
if (state.activeCell && !state.editingCell) {
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
core.startEdit(state.activeCell.row, state.activeCell.col);
|
|
465
|
+
}
|
|
466
|
+
break;
|
|
467
|
+
default:
|
|
468
|
+
// Start editing on any printable character
|
|
469
|
+
if (
|
|
470
|
+
state.activeCell &&
|
|
471
|
+
!state.editingCell &&
|
|
472
|
+
!isCtrl &&
|
|
473
|
+
e.key.length === 1
|
|
474
|
+
) {
|
|
475
|
+
core.startEdit(state.activeCell.row, state.activeCell.col);
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
[state.activeCell, state.editingCell]
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// Scroll active cell into view when navigating with keyboard
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
// Skip scrolling when editing - the user just clicked on the cell so it's already visible
|
|
486
|
+
if (!state.activeCell || !containerRef.current || state.editingCell) return;
|
|
487
|
+
|
|
488
|
+
const { row, col } = state.activeCell;
|
|
489
|
+
const container = containerRef.current;
|
|
490
|
+
|
|
491
|
+
// Calculate cell position
|
|
492
|
+
const cellTop = row * rowHeight + totalHeaderHeight;
|
|
493
|
+
const cellBottom = cellTop + rowHeight;
|
|
494
|
+
const cellLeft = columnPositions[col] ?? 0;
|
|
495
|
+
const cellRight = cellLeft + (columns[col]?.width ?? 0);
|
|
496
|
+
|
|
497
|
+
// Get visible area
|
|
498
|
+
const visibleTop = container.scrollTop + totalHeaderHeight;
|
|
499
|
+
const visibleBottom = container.scrollTop + container.clientHeight;
|
|
500
|
+
const visibleLeft = container.scrollLeft;
|
|
501
|
+
const visibleRight = container.scrollLeft + container.clientWidth;
|
|
502
|
+
|
|
503
|
+
// Scroll vertically if needed
|
|
504
|
+
if (cellTop < visibleTop) {
|
|
505
|
+
container.scrollTop = cellTop - totalHeaderHeight;
|
|
506
|
+
} else if (cellBottom > visibleBottom) {
|
|
507
|
+
container.scrollTop = cellBottom - container.clientHeight;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Scroll horizontally if needed
|
|
511
|
+
if (cellLeft < visibleLeft) {
|
|
512
|
+
container.scrollLeft = cellLeft;
|
|
513
|
+
} else if (cellRight > visibleRight) {
|
|
514
|
+
container.scrollLeft = cellRight - container.clientWidth;
|
|
515
|
+
}
|
|
516
|
+
}, [state.activeCell, state.editingCell, rowHeight, totalHeaderHeight, columnPositions, columns]);
|
|
517
|
+
|
|
518
|
+
// Cell click handler
|
|
519
|
+
const handleCellClick = useCallback(
|
|
520
|
+
(rowIndex: number, colIndex: number, e: React.MouseEvent) => {
|
|
521
|
+
// console.log("[GP-Grid] Cell click:", { rowIndex, colIndex, coreExists: !!coreRef.current });
|
|
522
|
+
const core = coreRef.current;
|
|
523
|
+
if (!core || core.getEditState() !== null) {
|
|
524
|
+
// console.warn("[GP-Grid] Core not initialized on cell click");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Focus the container to enable keyboard navigation
|
|
529
|
+
containerRef.current?.focus();
|
|
530
|
+
|
|
531
|
+
core.selection.startSelection(
|
|
532
|
+
{ row: rowIndex, col: colIndex },
|
|
533
|
+
{ shift: e.shiftKey, ctrl: e.ctrlKey || e.metaKey }
|
|
534
|
+
);
|
|
535
|
+
},
|
|
536
|
+
[]
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// Cell double-click handler
|
|
540
|
+
const handleCellDoubleClick = useCallback(
|
|
541
|
+
(rowIndex: number, colIndex: number) => {
|
|
542
|
+
const core = coreRef.current;
|
|
543
|
+
if (!core) return;
|
|
544
|
+
|
|
545
|
+
core.startEdit(rowIndex, colIndex);
|
|
546
|
+
},
|
|
547
|
+
[]
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// Header click handler (sort)
|
|
551
|
+
const handleHeaderClick = useCallback(
|
|
552
|
+
(colIndex: number, e: React.MouseEvent) => {
|
|
553
|
+
// console.log("[GP-Grid] Header click:", { colIndex, coreExists: !!coreRef.current });
|
|
554
|
+
const core = coreRef.current;
|
|
555
|
+
if (!core) {
|
|
556
|
+
// console.warn("[GP-Grid] Core not initialized on header click");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const column = columns[colIndex];
|
|
561
|
+
if (!column) {
|
|
562
|
+
// console.warn("[GP-Grid] Column not found for index:", colIndex);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const colId = column.colId ?? column.field;
|
|
567
|
+
const headerInfo = state.headers.get(colIndex);
|
|
568
|
+
const currentDirection = headerInfo?.sortDirection;
|
|
569
|
+
|
|
570
|
+
// Cycle: none -> asc -> desc -> none
|
|
571
|
+
let newDirection: SortDirection | null;
|
|
572
|
+
if (!currentDirection) {
|
|
573
|
+
newDirection = "asc";
|
|
574
|
+
} else if (currentDirection === "asc") {
|
|
575
|
+
newDirection = "desc";
|
|
576
|
+
} else {
|
|
577
|
+
newDirection = null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// console.log("[GP-Grid] Setting sort:", { colId, newDirection });
|
|
581
|
+
core.setSort(colId, newDirection, e.shiftKey);
|
|
582
|
+
},
|
|
583
|
+
[columns, state.headers]
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// Fill handle drag handlers
|
|
587
|
+
const handleFillHandleMouseDown = useCallback(
|
|
588
|
+
(e: React.MouseEvent) => {
|
|
589
|
+
// console.log("[GP-Grid] Fill handle mousedown triggered");
|
|
590
|
+
e.preventDefault();
|
|
591
|
+
e.stopPropagation();
|
|
592
|
+
|
|
593
|
+
const core = coreRef.current;
|
|
594
|
+
if (!core) return;
|
|
595
|
+
|
|
596
|
+
const { activeCell, selectionRange } = state;
|
|
597
|
+
if (!activeCell && !selectionRange) return;
|
|
598
|
+
|
|
599
|
+
// Create source range from selection or active cell
|
|
600
|
+
const sourceRange = selectionRange ?? {
|
|
601
|
+
startRow: activeCell!.row,
|
|
602
|
+
startCol: activeCell!.col,
|
|
603
|
+
endRow: activeCell!.row,
|
|
604
|
+
endCol: activeCell!.col,
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// console.log("[GP-Grid] Starting fill drag with source range:", sourceRange);
|
|
608
|
+
core.fill.startFillDrag(sourceRange);
|
|
609
|
+
setFillSourceRange(sourceRange);
|
|
610
|
+
setFillTarget({
|
|
611
|
+
row: Math.max(sourceRange.startRow, sourceRange.endRow),
|
|
612
|
+
col: Math.max(sourceRange.startCol, sourceRange.endCol)
|
|
613
|
+
});
|
|
614
|
+
setIsDraggingFill(true);
|
|
615
|
+
},
|
|
616
|
+
[state.activeCell, state.selectionRange]
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Handle mouse move during fill drag
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
if (!isDraggingFill) return;
|
|
622
|
+
|
|
623
|
+
// Auto-scroll configuration
|
|
624
|
+
const SCROLL_THRESHOLD = 40; // pixels from edge to trigger scroll
|
|
625
|
+
const SCROLL_SPEED = 10; // pixels per frame
|
|
626
|
+
|
|
627
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
628
|
+
const core = coreRef.current;
|
|
629
|
+
const container = containerRef.current;
|
|
630
|
+
if (!core || !container) return;
|
|
631
|
+
|
|
632
|
+
// Get container bounds
|
|
633
|
+
const rect = container.getBoundingClientRect();
|
|
634
|
+
const scrollLeft = container.scrollLeft;
|
|
635
|
+
const scrollTop = container.scrollTop;
|
|
636
|
+
|
|
637
|
+
// Calculate mouse position relative to grid content
|
|
638
|
+
const mouseX = e.clientX - rect.left + scrollLeft;
|
|
639
|
+
const mouseY = e.clientY - rect.top + scrollTop - totalHeaderHeight;
|
|
640
|
+
|
|
641
|
+
// Find the row and column under the mouse
|
|
642
|
+
const targetRow = Math.max(0, Math.floor(mouseY / rowHeight));
|
|
643
|
+
|
|
644
|
+
// Find column by checking column positions
|
|
645
|
+
let targetCol = 0;
|
|
646
|
+
for (let i = 0; i < columnPositions.length - 1; i++) {
|
|
647
|
+
if (mouseX >= columnPositions[i]! && mouseX < columnPositions[i + 1]!) {
|
|
648
|
+
targetCol = i;
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
if (mouseX >= columnPositions[columnPositions.length - 1]!) {
|
|
652
|
+
targetCol = columnPositions.length - 2;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
core.fill.updateFillDrag(targetRow, targetCol);
|
|
657
|
+
setFillTarget({ row: targetRow, col: targetCol });
|
|
658
|
+
|
|
659
|
+
// Auto-scroll logic
|
|
660
|
+
const mouseYInContainer = e.clientY - rect.top;
|
|
661
|
+
const mouseXInContainer = e.clientX - rect.left;
|
|
662
|
+
|
|
663
|
+
// Clear any existing auto-scroll
|
|
664
|
+
if (autoScrollIntervalRef.current) {
|
|
665
|
+
clearInterval(autoScrollIntervalRef.current);
|
|
666
|
+
autoScrollIntervalRef.current = null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Check if we need to auto-scroll
|
|
670
|
+
let scrollDeltaX = 0;
|
|
671
|
+
let scrollDeltaY = 0;
|
|
672
|
+
|
|
673
|
+
// Vertical scrolling
|
|
674
|
+
if (mouseYInContainer < SCROLL_THRESHOLD + totalHeaderHeight) {
|
|
675
|
+
scrollDeltaY = -SCROLL_SPEED;
|
|
676
|
+
} else if (mouseYInContainer > rect.height - SCROLL_THRESHOLD) {
|
|
677
|
+
scrollDeltaY = SCROLL_SPEED;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Horizontal scrolling
|
|
681
|
+
if (mouseXInContainer < SCROLL_THRESHOLD) {
|
|
682
|
+
scrollDeltaX = -SCROLL_SPEED;
|
|
683
|
+
} else if (mouseXInContainer > rect.width - SCROLL_THRESHOLD) {
|
|
684
|
+
scrollDeltaX = SCROLL_SPEED;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Start auto-scroll if needed
|
|
688
|
+
if (scrollDeltaX !== 0 || scrollDeltaY !== 0) {
|
|
689
|
+
autoScrollIntervalRef.current = setInterval(() => {
|
|
690
|
+
if (containerRef.current) {
|
|
691
|
+
containerRef.current.scrollTop += scrollDeltaY;
|
|
692
|
+
containerRef.current.scrollLeft += scrollDeltaX;
|
|
693
|
+
}
|
|
694
|
+
}, 16); // ~60fps
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const handleMouseUp = () => {
|
|
699
|
+
// Clear auto-scroll
|
|
700
|
+
if (autoScrollIntervalRef.current) {
|
|
701
|
+
clearInterval(autoScrollIntervalRef.current);
|
|
702
|
+
autoScrollIntervalRef.current = null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const core = coreRef.current;
|
|
706
|
+
if (core) {
|
|
707
|
+
core.fill.commitFillDrag();
|
|
708
|
+
// Refresh slots to show updated values
|
|
709
|
+
core.refreshSlotData();
|
|
710
|
+
}
|
|
711
|
+
setIsDraggingFill(false);
|
|
712
|
+
setFillTarget(null);
|
|
713
|
+
setFillSourceRange(null);
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
document.addEventListener("mousemove", handleMouseMove);
|
|
717
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
718
|
+
|
|
719
|
+
return () => {
|
|
720
|
+
// Clear auto-scroll on cleanup
|
|
721
|
+
if (autoScrollIntervalRef.current) {
|
|
722
|
+
clearInterval(autoScrollIntervalRef.current);
|
|
723
|
+
autoScrollIntervalRef.current = null;
|
|
724
|
+
}
|
|
725
|
+
document.removeEventListener("mousemove", handleMouseMove);
|
|
726
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
727
|
+
};
|
|
728
|
+
}, [isDraggingFill, totalHeaderHeight, rowHeight, columnPositions]);
|
|
729
|
+
|
|
730
|
+
// Render helpers
|
|
731
|
+
const isSelected = useCallback(
|
|
732
|
+
(row: number, col: number): boolean => {
|
|
733
|
+
const { selectionRange } = state;
|
|
734
|
+
if (!selectionRange) return false;
|
|
735
|
+
|
|
736
|
+
const minRow = Math.min(selectionRange.startRow, selectionRange.endRow);
|
|
737
|
+
const maxRow = Math.max(selectionRange.startRow, selectionRange.endRow);
|
|
738
|
+
const minCol = Math.min(selectionRange.startCol, selectionRange.endCol);
|
|
739
|
+
const maxCol = Math.max(selectionRange.startCol, selectionRange.endCol);
|
|
740
|
+
|
|
741
|
+
return row >= minRow && row <= maxRow && col >= minCol && col <= maxCol;
|
|
742
|
+
},
|
|
743
|
+
[state.selectionRange]
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const isActiveCell = useCallback(
|
|
747
|
+
(row: number, col: number): boolean => {
|
|
748
|
+
return state.activeCell?.row === row && state.activeCell?.col === col;
|
|
749
|
+
},
|
|
750
|
+
[state.activeCell]
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
const isEditingCell = useCallback(
|
|
754
|
+
(row: number, col: number): boolean => {
|
|
755
|
+
return state.editingCell?.row === row && state.editingCell?.col === col;
|
|
756
|
+
},
|
|
757
|
+
[state.editingCell]
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
// Check if cell is in fill preview range
|
|
761
|
+
const isInFillPreview = useCallback(
|
|
762
|
+
(row: number, col: number): boolean => {
|
|
763
|
+
if (!isDraggingFill || !fillSourceRange || !fillTarget) return false;
|
|
764
|
+
|
|
765
|
+
const srcMinRow = Math.min(fillSourceRange.startRow, fillSourceRange.endRow);
|
|
766
|
+
const srcMaxRow = Math.max(fillSourceRange.startRow, fillSourceRange.endRow);
|
|
767
|
+
const srcMinCol = Math.min(fillSourceRange.startCol, fillSourceRange.endCol);
|
|
768
|
+
const srcMaxCol = Math.max(fillSourceRange.startCol, fillSourceRange.endCol);
|
|
769
|
+
|
|
770
|
+
// Determine fill direction and range
|
|
771
|
+
const fillDown = fillTarget.row > srcMaxRow;
|
|
772
|
+
const fillUp = fillTarget.row < srcMinRow;
|
|
773
|
+
const fillRight = fillTarget.col > srcMaxCol;
|
|
774
|
+
const fillLeft = fillTarget.col < srcMinCol;
|
|
775
|
+
|
|
776
|
+
// Check if cell is in the fill preview area (not the source area)
|
|
777
|
+
if (fillDown) {
|
|
778
|
+
return row > srcMaxRow && row <= fillTarget.row && col >= srcMinCol && col <= srcMaxCol;
|
|
779
|
+
}
|
|
780
|
+
if (fillUp) {
|
|
781
|
+
return row < srcMinRow && row >= fillTarget.row && col >= srcMinCol && col <= srcMaxCol;
|
|
782
|
+
}
|
|
783
|
+
if (fillRight) {
|
|
784
|
+
return col > srcMaxCol && col <= fillTarget.col && row >= srcMinRow && row <= srcMaxRow;
|
|
785
|
+
}
|
|
786
|
+
if (fillLeft) {
|
|
787
|
+
return col < srcMinCol && col >= fillTarget.col && row >= srcMinRow && row <= srcMaxRow;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return false;
|
|
791
|
+
},
|
|
792
|
+
[isDraggingFill, fillSourceRange, fillTarget]
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
// Get cell value from row data
|
|
796
|
+
const getCellValue = useCallback((rowData: Row, field: string): CellValue => {
|
|
797
|
+
const parts = field.split(".");
|
|
798
|
+
let value: unknown = rowData;
|
|
799
|
+
|
|
800
|
+
for (const part of parts) {
|
|
801
|
+
if (value == null || typeof value !== "object") {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
value = (value as Record<string, unknown>)[part];
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return (value ?? null) as CellValue;
|
|
808
|
+
}, []);
|
|
809
|
+
|
|
810
|
+
// Render cell content
|
|
811
|
+
const renderCell = useCallback(
|
|
812
|
+
(
|
|
813
|
+
column: ColumnDefinition,
|
|
814
|
+
rowData: Row,
|
|
815
|
+
rowIndex: number,
|
|
816
|
+
colIndex: number
|
|
817
|
+
): React.ReactNode => {
|
|
818
|
+
const value = getCellValue(rowData, column.field);
|
|
819
|
+
const params: CellRendererParams = {
|
|
820
|
+
value,
|
|
821
|
+
rowData,
|
|
822
|
+
column,
|
|
823
|
+
rowIndex,
|
|
824
|
+
colIndex,
|
|
825
|
+
isActive: isActiveCell(rowIndex, colIndex),
|
|
826
|
+
isSelected: isSelected(rowIndex, colIndex),
|
|
827
|
+
isEditing: isEditingCell(rowIndex, colIndex),
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
// Check for column-specific renderer
|
|
831
|
+
if (column.cellRenderer && typeof column.cellRenderer === "string") {
|
|
832
|
+
const renderer = cellRenderers[column.cellRenderer];
|
|
833
|
+
if (renderer) {
|
|
834
|
+
return renderer(params);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Fall back to global renderer
|
|
839
|
+
if (cellRenderer) {
|
|
840
|
+
return cellRenderer(params);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Default text rendering
|
|
844
|
+
return value == null ? "" : String(value);
|
|
845
|
+
},
|
|
846
|
+
[getCellValue, isActiveCell, isSelected, isEditingCell, cellRenderers, cellRenderer]
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
// Render edit cell
|
|
850
|
+
const renderEditCell = useCallback(
|
|
851
|
+
(
|
|
852
|
+
column: ColumnDefinition,
|
|
853
|
+
rowData: Row,
|
|
854
|
+
rowIndex: number,
|
|
855
|
+
colIndex: number,
|
|
856
|
+
initialValue: CellValue
|
|
857
|
+
): React.ReactNode => {
|
|
858
|
+
const core = coreRef.current;
|
|
859
|
+
if (!core) return null;
|
|
860
|
+
|
|
861
|
+
const value = getCellValue(rowData, column.field);
|
|
862
|
+
const params: EditRendererParams = {
|
|
863
|
+
value,
|
|
864
|
+
rowData,
|
|
865
|
+
column,
|
|
866
|
+
rowIndex,
|
|
867
|
+
colIndex,
|
|
868
|
+
isActive: true,
|
|
869
|
+
isSelected: true,
|
|
870
|
+
isEditing: true,
|
|
871
|
+
initialValue,
|
|
872
|
+
onValueChange: (newValue) => core.updateEditValue(newValue),
|
|
873
|
+
onCommit: () => core.commitEdit(),
|
|
874
|
+
onCancel: () => core.cancelEdit(),
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// Check for column-specific renderer
|
|
878
|
+
if (column.editRenderer && typeof column.editRenderer === "string") {
|
|
879
|
+
const renderer = editRenderers[column.editRenderer];
|
|
880
|
+
if (renderer) {
|
|
881
|
+
return renderer(params);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Fall back to global renderer
|
|
886
|
+
if (editRenderer) {
|
|
887
|
+
return editRenderer(params);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Default input
|
|
891
|
+
return (
|
|
892
|
+
<input
|
|
893
|
+
className="gp-grid-edit-input"
|
|
894
|
+
type="text"
|
|
895
|
+
defaultValue={initialValue == null ? "" : String(initialValue)}
|
|
896
|
+
autoFocus
|
|
897
|
+
onFocus={(e) => e.target.select()}
|
|
898
|
+
onChange={(e) => core.updateEditValue(e.target.value)}
|
|
899
|
+
onKeyDown={(e) => {
|
|
900
|
+
e.stopPropagation();
|
|
901
|
+
if (e.key === "Enter") {
|
|
902
|
+
core.commitEdit();
|
|
903
|
+
} else if (e.key === "Escape") {
|
|
904
|
+
core.cancelEdit();
|
|
905
|
+
} else if (e.key === "Tab") {
|
|
906
|
+
e.preventDefault();
|
|
907
|
+
core.commitEdit();
|
|
908
|
+
core.selection.moveFocus(e.shiftKey ? "left" : "right", false);
|
|
909
|
+
}
|
|
910
|
+
}}
|
|
911
|
+
onBlur={() => core.commitEdit()}
|
|
912
|
+
/>
|
|
913
|
+
);
|
|
914
|
+
},
|
|
915
|
+
[getCellValue, editRenderers, editRenderer]
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
// Render header
|
|
919
|
+
const renderHeader = useCallback(
|
|
920
|
+
(
|
|
921
|
+
column: ColumnDefinition,
|
|
922
|
+
colIndex: number,
|
|
923
|
+
sortDirection?: SortDirection,
|
|
924
|
+
sortIndex?: number
|
|
925
|
+
): React.ReactNode => {
|
|
926
|
+
const core = coreRef.current;
|
|
927
|
+
const params: HeaderRendererParams = {
|
|
928
|
+
column,
|
|
929
|
+
colIndex,
|
|
930
|
+
sortDirection,
|
|
931
|
+
sortIndex,
|
|
932
|
+
onSort: (direction, addToExisting) => {
|
|
933
|
+
if (core) {
|
|
934
|
+
core.setSort(column.colId ?? column.field, direction, addToExisting);
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
// Check for column-specific renderer
|
|
940
|
+
if (column.headerRenderer && typeof column.headerRenderer === "string") {
|
|
941
|
+
const renderer = headerRenderers[column.headerRenderer];
|
|
942
|
+
if (renderer) {
|
|
943
|
+
return renderer(params);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Fall back to global renderer
|
|
948
|
+
if (headerRenderer) {
|
|
949
|
+
return headerRenderer(params);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Default header
|
|
953
|
+
return (
|
|
954
|
+
<>
|
|
955
|
+
<span className="gp-grid-header-text">
|
|
956
|
+
{column.headerName ?? column.field}
|
|
957
|
+
</span>
|
|
958
|
+
{sortDirection && (
|
|
959
|
+
<span className="gp-grid-sort-indicator">
|
|
960
|
+
{sortDirection === "asc" ? "▲" : "▼"}
|
|
961
|
+
{sortIndex !== undefined && sortIndex > 0 && (
|
|
962
|
+
<span className="gp-grid-sort-index">{sortIndex}</span>
|
|
963
|
+
)}
|
|
964
|
+
</span>
|
|
965
|
+
)}
|
|
966
|
+
</>
|
|
967
|
+
);
|
|
968
|
+
},
|
|
969
|
+
[headerRenderers, headerRenderer]
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
// Convert slots map to array for rendering
|
|
973
|
+
const slotsArray = useMemo(() => Array.from(state.slots.values()), [state.slots]);
|
|
974
|
+
|
|
975
|
+
// Calculate fill handle position (only show for editable columns)
|
|
976
|
+
const fillHandlePosition = useMemo(() => {
|
|
977
|
+
const { activeCell, selectionRange } = state;
|
|
978
|
+
if (!activeCell && !selectionRange) return null;
|
|
979
|
+
|
|
980
|
+
// Get the bottom-right corner and column range of selection or active cell
|
|
981
|
+
let row: number, col: number;
|
|
982
|
+
let minCol: number, maxCol: number;
|
|
983
|
+
|
|
984
|
+
if (selectionRange) {
|
|
985
|
+
row = Math.max(selectionRange.startRow, selectionRange.endRow);
|
|
986
|
+
col = Math.max(selectionRange.startCol, selectionRange.endCol);
|
|
987
|
+
minCol = Math.min(selectionRange.startCol, selectionRange.endCol);
|
|
988
|
+
maxCol = Math.max(selectionRange.startCol, selectionRange.endCol);
|
|
989
|
+
} else if (activeCell) {
|
|
990
|
+
row = activeCell.row;
|
|
991
|
+
col = activeCell.col;
|
|
992
|
+
minCol = col;
|
|
993
|
+
maxCol = col;
|
|
994
|
+
} else {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Check if ALL columns in the selection are editable
|
|
999
|
+
for (let c = minCol; c <= maxCol; c++) {
|
|
1000
|
+
const column = columns[c];
|
|
1001
|
+
if (!column || column.editable !== true) {
|
|
1002
|
+
return null; // Don't show fill handle if any column is not editable
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const cellTop = row * rowHeight + totalHeaderHeight;
|
|
1007
|
+
const cellLeft = columnPositions[col] ?? 0;
|
|
1008
|
+
const cellWidth = columns[col]?.width ?? 0;
|
|
1009
|
+
|
|
1010
|
+
return {
|
|
1011
|
+
top: cellTop + rowHeight - 5,
|
|
1012
|
+
left: cellLeft + cellWidth - 20, // Move significantly left to avoid scrollbar overlap
|
|
1013
|
+
};
|
|
1014
|
+
}, [state.activeCell, state.selectionRange, rowHeight, totalHeaderHeight, columnPositions, columns]);
|
|
1015
|
+
|
|
1016
|
+
return (
|
|
1017
|
+
<div
|
|
1018
|
+
ref={containerRef}
|
|
1019
|
+
className={`gp-grid-container${darkMode ? " gp-grid-container--dark" : ""}`}
|
|
1020
|
+
style={{
|
|
1021
|
+
width: "100%",
|
|
1022
|
+
height: "100%",
|
|
1023
|
+
overflow: "auto",
|
|
1024
|
+
position: "relative",
|
|
1025
|
+
}}
|
|
1026
|
+
onScroll={handleScroll}
|
|
1027
|
+
onKeyDown={handleKeyDown}
|
|
1028
|
+
tabIndex={0}
|
|
1029
|
+
>
|
|
1030
|
+
{/* Content sizer */}
|
|
1031
|
+
<div
|
|
1032
|
+
style={{
|
|
1033
|
+
width: Math.max(state.contentWidth, totalWidth),
|
|
1034
|
+
height: Math.max(state.contentHeight, totalHeaderHeight),
|
|
1035
|
+
position: "relative",
|
|
1036
|
+
minWidth: "100%",
|
|
1037
|
+
}}
|
|
1038
|
+
>
|
|
1039
|
+
{/* Headers */}
|
|
1040
|
+
<div
|
|
1041
|
+
className="gp-grid-header"
|
|
1042
|
+
style={{
|
|
1043
|
+
position: "sticky",
|
|
1044
|
+
top: 0,
|
|
1045
|
+
left: 0,
|
|
1046
|
+
height: headerHeight,
|
|
1047
|
+
width: Math.max(state.contentWidth, totalWidth),
|
|
1048
|
+
minWidth: "100%",
|
|
1049
|
+
zIndex: 100,
|
|
1050
|
+
}}
|
|
1051
|
+
>
|
|
1052
|
+
{columns.map((column, colIndex) => {
|
|
1053
|
+
const headerInfo = state.headers.get(colIndex);
|
|
1054
|
+
return (
|
|
1055
|
+
<div
|
|
1056
|
+
key={column.colId ?? column.field}
|
|
1057
|
+
className="gp-grid-header-cell"
|
|
1058
|
+
style={{
|
|
1059
|
+
position: "absolute",
|
|
1060
|
+
left: `${columnPositions[colIndex]}px`,
|
|
1061
|
+
top: 0,
|
|
1062
|
+
width: `${column.width}px`,
|
|
1063
|
+
height: `${headerHeight}px`,
|
|
1064
|
+
background: "transparent",
|
|
1065
|
+
}}
|
|
1066
|
+
onClick={(e) => handleHeaderClick(colIndex, e)}
|
|
1067
|
+
>
|
|
1068
|
+
{renderHeader(
|
|
1069
|
+
column,
|
|
1070
|
+
colIndex,
|
|
1071
|
+
headerInfo?.sortDirection,
|
|
1072
|
+
headerInfo?.sortIndex
|
|
1073
|
+
)}
|
|
1074
|
+
</div>
|
|
1075
|
+
);
|
|
1076
|
+
})}
|
|
1077
|
+
</div>
|
|
1078
|
+
|
|
1079
|
+
{/* Filter Row */}
|
|
1080
|
+
{showFilters && (
|
|
1081
|
+
<div
|
|
1082
|
+
className="gp-grid-filter-row"
|
|
1083
|
+
style={{
|
|
1084
|
+
position: "sticky",
|
|
1085
|
+
top: headerHeight,
|
|
1086
|
+
left: 0,
|
|
1087
|
+
height: filterRowHeight,
|
|
1088
|
+
width: Math.max(state.contentWidth, totalWidth),
|
|
1089
|
+
minWidth: "100%",
|
|
1090
|
+
zIndex: 99,
|
|
1091
|
+
}}
|
|
1092
|
+
>
|
|
1093
|
+
{columns.map((column, colIndex) => {
|
|
1094
|
+
const colId = column.colId ?? column.field;
|
|
1095
|
+
return (
|
|
1096
|
+
<div
|
|
1097
|
+
key={`filter-${colId}`}
|
|
1098
|
+
className="gp-grid-filter-cell"
|
|
1099
|
+
style={{
|
|
1100
|
+
position: "absolute",
|
|
1101
|
+
left: `${columnPositions[colIndex]}px`,
|
|
1102
|
+
top: 0,
|
|
1103
|
+
width: `${column.width}px`,
|
|
1104
|
+
height: `${filterRowHeight}px`,
|
|
1105
|
+
}}
|
|
1106
|
+
>
|
|
1107
|
+
<input
|
|
1108
|
+
className="gp-grid-filter-input"
|
|
1109
|
+
type="text"
|
|
1110
|
+
placeholder={`Filter ${column.headerName ?? column.field}...`}
|
|
1111
|
+
value={filterValues[colId] ?? ""}
|
|
1112
|
+
onChange={(e) => handleFilterChange(colId, e.target.value)}
|
|
1113
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
1114
|
+
/>
|
|
1115
|
+
</div>
|
|
1116
|
+
);
|
|
1117
|
+
})}
|
|
1118
|
+
</div>
|
|
1119
|
+
)}
|
|
1120
|
+
|
|
1121
|
+
{/* Row slots */}
|
|
1122
|
+
{slotsArray.map((slot) => {
|
|
1123
|
+
if (slot.rowIndex < 0) return null;
|
|
1124
|
+
|
|
1125
|
+
const isEvenRow = slot.rowIndex % 2 === 0;
|
|
1126
|
+
|
|
1127
|
+
return (
|
|
1128
|
+
<div
|
|
1129
|
+
key={slot.slotId}
|
|
1130
|
+
className={`gp-grid-row ${isEvenRow ? "gp-grid-row--even" : ""}`}
|
|
1131
|
+
style={{
|
|
1132
|
+
position: "absolute",
|
|
1133
|
+
top: 0,
|
|
1134
|
+
left: 0,
|
|
1135
|
+
transform: `translateY(${slot.translateY}px)`,
|
|
1136
|
+
width: `${Math.max(state.contentWidth, totalWidth)}px`,
|
|
1137
|
+
height: `${rowHeight}px`,
|
|
1138
|
+
}}
|
|
1139
|
+
>
|
|
1140
|
+
{columns.map((column, colIndex) => {
|
|
1141
|
+
const isEditing = isEditingCell(slot.rowIndex, colIndex);
|
|
1142
|
+
const active = isActiveCell(slot.rowIndex, colIndex);
|
|
1143
|
+
const selected = isSelected(slot.rowIndex, colIndex);
|
|
1144
|
+
const inFillPreview = isInFillPreview(slot.rowIndex, colIndex);
|
|
1145
|
+
|
|
1146
|
+
const cellClasses = [
|
|
1147
|
+
"gp-grid-cell",
|
|
1148
|
+
active && "gp-grid-cell--active",
|
|
1149
|
+
selected && !active && "gp-grid-cell--selected",
|
|
1150
|
+
isEditing && "gp-grid-cell--editing",
|
|
1151
|
+
inFillPreview && "gp-grid-cell--fill-preview",
|
|
1152
|
+
]
|
|
1153
|
+
.filter(Boolean)
|
|
1154
|
+
.join(" ");
|
|
1155
|
+
|
|
1156
|
+
return (
|
|
1157
|
+
<div
|
|
1158
|
+
key={`${slot.slotId}-${colIndex}`}
|
|
1159
|
+
className={cellClasses}
|
|
1160
|
+
style={{
|
|
1161
|
+
position: "absolute",
|
|
1162
|
+
left: `${columnPositions[colIndex]}px`,
|
|
1163
|
+
top: 0,
|
|
1164
|
+
width: `${column.width}px`,
|
|
1165
|
+
height: `${rowHeight}px`,
|
|
1166
|
+
}}
|
|
1167
|
+
onClick={(e) => handleCellClick(slot.rowIndex, colIndex, e)}
|
|
1168
|
+
onDoubleClick={() => handleCellDoubleClick(slot.rowIndex, colIndex)}
|
|
1169
|
+
>
|
|
1170
|
+
{isEditing && state.editingCell
|
|
1171
|
+
? renderEditCell(
|
|
1172
|
+
column,
|
|
1173
|
+
slot.rowData,
|
|
1174
|
+
slot.rowIndex,
|
|
1175
|
+
colIndex,
|
|
1176
|
+
state.editingCell.initialValue
|
|
1177
|
+
)
|
|
1178
|
+
: renderCell(column, slot.rowData, slot.rowIndex, colIndex)}
|
|
1179
|
+
</div>
|
|
1180
|
+
);
|
|
1181
|
+
})}
|
|
1182
|
+
</div>
|
|
1183
|
+
);
|
|
1184
|
+
})}
|
|
1185
|
+
|
|
1186
|
+
{/* Fill handle (drag to fill) */}
|
|
1187
|
+
{fillHandlePosition && !state.editingCell && (
|
|
1188
|
+
<div
|
|
1189
|
+
className="gp-grid-fill-handle"
|
|
1190
|
+
style={{
|
|
1191
|
+
position: "absolute",
|
|
1192
|
+
top: fillHandlePosition.top,
|
|
1193
|
+
left: fillHandlePosition.left,
|
|
1194
|
+
zIndex: 200,
|
|
1195
|
+
}}
|
|
1196
|
+
onMouseDown={handleFillHandleMouseDown}
|
|
1197
|
+
/>
|
|
1198
|
+
)}
|
|
1199
|
+
|
|
1200
|
+
{/* Loading indicator */}
|
|
1201
|
+
{state.isLoading && (
|
|
1202
|
+
<div className="gp-grid-loading">
|
|
1203
|
+
<div className="gp-grid-loading-spinner" />
|
|
1204
|
+
Loading...
|
|
1205
|
+
</div>
|
|
1206
|
+
)}
|
|
1207
|
+
|
|
1208
|
+
{/* Error message */}
|
|
1209
|
+
{state.error && (
|
|
1210
|
+
<div className="gp-grid-error">Error: {state.error}</div>
|
|
1211
|
+
)}
|
|
1212
|
+
|
|
1213
|
+
{/* Empty state */}
|
|
1214
|
+
{!state.isLoading && !state.error && state.totalRows === 0 && (
|
|
1215
|
+
<div className="gp-grid-empty">No data to display</div>
|
|
1216
|
+
)}
|
|
1217
|
+
</div>
|
|
1218
|
+
</div>
|
|
1219
|
+
);
|
|
1220
|
+
}
|