@warkypublic/svelix 0.1.46 → 0.1.47

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.
Files changed (97) hide show
  1. package/README.md +93 -33
  2. package/dist/components/BetterMenu/BetterMenu.svelte +21 -37
  3. package/dist/components/BetterMenu/BetterMenuAsyncButton.svelte +21 -31
  4. package/dist/components/BetterMenu/BetterMenuPreview.svelte +27 -29
  5. package/dist/components/BetterMenu/MenuRenderer.svelte +40 -51
  6. package/dist/components/Boxer/Boxer.svelte +488 -602
  7. package/dist/components/Boxer/BoxerTarget.svelte +6 -46
  8. package/dist/components/Boxer/BoxerTarget.svelte.d.ts +1 -0
  9. package/dist/components/Boxer/types.d.ts +0 -1
  10. package/dist/components/Button.svelte +19 -43
  11. package/dist/components/ButtonPreview.svelte +2 -11
  12. package/dist/components/CardGrid/CardGrid.svelte +443 -614
  13. package/dist/components/CardGrid/CardGridFilterPanel.svelte +111 -90
  14. package/dist/components/CardGrid/DefaultCard.svelte +80 -96
  15. package/dist/components/CardGrid/ImageCardStory.svelte +87 -36
  16. package/dist/components/ContentEditor/CollaboraInsertTextDemo.svelte +5 -10
  17. package/dist/components/ContentEditor/ContentEditor.svelte +24 -50
  18. package/dist/components/ContentEditor/subcomponents/AudioPlayer.svelte +17 -17
  19. package/dist/components/ContentEditor/subcomponents/CollaboraEditor.svelte +244 -309
  20. package/dist/components/ContentEditor/subcomponents/EmailViewer.svelte +2 -3
  21. package/dist/components/ContentEditor/subcomponents/ImageViewer.svelte +14 -15
  22. package/dist/components/ContentEditor/subcomponents/MarkdownViewer.svelte +74 -90
  23. package/dist/components/ContentEditor/subcomponents/MonacoEditor.svelte +75 -103
  24. package/dist/components/ContentEditor/subcomponents/Office365Editor.svelte +234 -297
  25. package/dist/components/ContentEditor/subcomponents/PdfViewer.svelte +14 -20
  26. package/dist/components/ContentEditor/subcomponents/TextEditor.svelte +122 -150
  27. package/dist/components/ContentEditor/subcomponents/UnknownFile.svelte +105 -126
  28. package/dist/components/ContentEditor/subcomponents/VideoPlayer.svelte +17 -17
  29. package/dist/components/ContentEditor/subcomponents/ZipViewer.svelte +2 -3
  30. package/dist/components/ErrorBoundary/ErrorBoundary.svelte +41 -58
  31. package/dist/components/ErrorBoundary/ErrorBoundaryPreview.svelte +8 -16
  32. package/dist/components/Former/Former.svelte +306 -424
  33. package/dist/components/Former/FormerButtonArea.svelte +17 -68
  34. package/dist/components/Former/FormerDrawer.svelte +26 -78
  35. package/dist/components/Former/FormerDrawerPreview.svelte +99 -89
  36. package/dist/components/Former/FormerModal.svelte +24 -70
  37. package/dist/components/Former/FormerModalPreview.svelte +99 -89
  38. package/dist/components/Former/FormerPreview.svelte +76 -78
  39. package/dist/components/Former/FormerRestApiPreview.svelte +55 -48
  40. package/dist/components/FormerControllers/ButtonCtrl.svelte +13 -44
  41. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrl.svelte +280 -445
  42. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlCalendar.svelte +95 -136
  43. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlPickerPanel.svelte +3 -62
  44. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlTimeFields.svelte +143 -217
  45. package/dist/components/FormerControllers/IconButtonCtrl.svelte +13 -42
  46. package/dist/components/FormerControllers/InlineWrapper.svelte +12 -41
  47. package/dist/components/FormerControllers/InlineWrapperPreview.svelte +4 -26
  48. package/dist/components/FormerControllers/NativeSelectCtrl.svelte +3 -29
  49. package/dist/components/FormerControllers/NumberInputCtrl.svelte +9 -35
  50. package/dist/components/FormerControllers/PasswordInputCtrl.svelte +4 -23
  51. package/dist/components/FormerControllers/SwitchCtrl.svelte +3 -20
  52. package/dist/components/FormerControllers/TextAreaCtrl.svelte +3 -24
  53. package/dist/components/FormerControllers/TextInputCtrl.svelte +3 -24
  54. package/dist/components/GlobalStateStore/GlobalStateStoreProvider.svelte +73 -120
  55. package/dist/components/Gridler/components/Gridler.svelte +338 -442
  56. package/dist/components/Gridler/components/GridlerCanvas.svelte +720 -1115
  57. package/dist/components/Gridler/components/GridlerEditor.svelte +2 -15
  58. package/dist/components/Gridler/components/GridlerFull.svelte +520 -841
  59. package/dist/components/Gridler/components/GridlerFull.svelte.d.ts +1 -1
  60. package/dist/components/Gridler/components/GridlerFullWithFormerPreview.svelte +194 -147
  61. package/dist/components/Gridler/components/GridlerSearch.svelte +7 -16
  62. package/dist/components/Gridler/components/GridlerSearchToggle.svelte +7 -19
  63. package/dist/components/Gridler/types.d.ts +1 -1
  64. package/dist/components/Icons/IconAdd.svelte +1 -3
  65. package/dist/components/Icons/IconAlertCircle.svelte +1 -3
  66. package/dist/components/Icons/IconAlertTriangle.svelte +1 -3
  67. package/dist/components/Icons/IconCamera.svelte +1 -3
  68. package/dist/components/Icons/IconClose.svelte +1 -3
  69. package/dist/components/Icons/IconEdit.svelte +1 -3
  70. package/dist/components/Icons/IconFilter.svelte +1 -3
  71. package/dist/components/Icons/IconSearch.svelte +1 -3
  72. package/dist/components/Icons/IconSort.svelte +1 -3
  73. package/dist/components/Icons/IconTrash.svelte +1 -3
  74. package/dist/components/Portal/Portal.svelte +14 -38
  75. package/dist/components/Screenshot/Screenshot.svelte +7 -13
  76. package/dist/components/Svark/Svark.svelte +680 -852
  77. package/dist/components/Svark/SvarkContextMenu.svelte +44 -63
  78. package/dist/components/Svark/SvarkInfiniteLoader.svelte +2 -6
  79. package/dist/components/Svark/SvarkPager.svelte +2 -28
  80. package/dist/components/Svark/SvarkSelectionDemo.svelte +4 -8
  81. package/dist/components/Svark/SvarkTopBar.svelte +9 -74
  82. package/dist/components/SvarkGrid/SvarkGrid.svelte +877 -1123
  83. package/dist/components/SvarkGrid/components/SvarkColumnFilterForm.svelte +45 -58
  84. package/dist/components/SvarkGrid/components/SvarkContextMenu.svelte +44 -63
  85. package/dist/components/SvarkGrid/components/SvarkHeaderFilterCell.svelte +41 -75
  86. package/dist/components/SvarkGrid/components/SvarkInfiniteLoader.svelte +2 -6
  87. package/dist/components/SvarkGrid/components/SvarkPager.svelte +2 -28
  88. package/dist/components/SvarkGrid/components/SvarkSearchPopover.svelte +8 -16
  89. package/dist/components/SvarkGrid/components/SvarkTopBar.svelte +6 -62
  90. package/dist/components/SvarkGrid/internal/SvarkGridView.svelte +345 -507
  91. package/dist/components/VTree/VTree.svelte +349 -431
  92. package/dist/components/VTree/VTreeContextMenu.svelte +21 -33
  93. package/dist/components/VTree/VTreeEventsDemo.svelte +48 -58
  94. package/dist/components/VTree/VTreeRow.svelte +13 -65
  95. package/dist/components/VTree/VTreeSearch.svelte +6 -13
  96. package/dist/components/VTree/VTreeVirtualViewport.svelte +37 -106
  97. package/package.json +30 -29
@@ -1,1118 +1,723 @@
1
- <script lang="ts">
2
- // @ts-nocheck
3
- import type { Snippet } from "svelte";
4
- import type {
5
- GridlerColumn,
6
- GridlerCell,
7
- GridlerTheme,
8
- Item,
9
- Selection,
10
- VisibleRange,
11
- GridColumnSortOrder,
12
- } from "../types";
13
- import type { GridColumn } from "../../Types/generic_grid";
14
- import {
15
- calculateVisibleRange,
16
- getColumnX,
17
- getTotalHeight,
18
- getCellFromPoint,
19
- scrollToCell,
20
- getResizeHandle,
21
- getColumnFromX,
22
- } from "../utils/scrolling";
23
- import { computeEffectiveColumns } from "../utils/columns";
24
- import {
25
- renderFrame,
26
- getScrollbarGeometry,
27
- } from "../utils/canvasRender";
28
- import { IMAGE_LOADED_EVENT } from "../renderers/ImageRenderer";
29
- import type { GridlerRenderContext } from "../utils/canvasRender";
30
- import GridlerEditor from "./GridlerEditor.svelte";
31
- import GridlerSearch from "./GridlerSearch.svelte";
32
-
33
- interface Props {
34
- columns: GridlerColumn[];
35
- rows: number;
36
- getCellContent: (item: Item) => GridlerCell;
37
- width: string;
38
- height: string;
39
- mergedTheme: GridlerTheme;
40
- isDarkMode?: boolean;
41
- headerHeight?: number;
42
- rowHeight?: number;
43
- readonly?: boolean;
44
- fixedColumns?: number;
45
- resizableColumns?: boolean;
46
- rowMarkers?: "checkbox" | "number" | "none";
47
- showSearch?: boolean;
48
- searchValue?: string;
49
- // editing state (driven by parent)
50
- isEditing: boolean;
51
- editingCell: Item | null;
52
- // callbacks to parent
53
- onBeginEdit: (cell: Item) => void;
54
- onEditComplete: (value: string) => void;
55
- onCancelEdit: () => void;
56
- onRowToggle?: (row: number) => void;
57
- onVisibleRangeChange?: (range: VisibleRange) => void;
58
- onSelectionChange?: (sel: Selection) => void;
59
- onCellDblClick?: (item: Item, cell: GridlerCell) => void;
60
- onRowClick?: (row: number) => void;
61
- onRowContextMenu?: (row: number, x: number, y: number) => void;
62
- onColumnMoved?: (from: number, to: number) => void;
63
- onCellEdited?: (item: Item, cell: GridlerCell) => void;
64
- onDelete?: (sel: Selection) => void;
65
- onRowAppended?: () => void;
66
- onSelectionCleared?: () => void;
67
- sortOrder?: GridColumnSortOrder;
68
- onSortOrderChange?: (order: GridColumnSortOrder) => void;
69
- onHeaderContextMenu?: (colIndex: number, x: number, y: number) => void;
70
- onSearchValueChange?: (v: string) => void;
71
- onSearchClose?: () => void;
72
- onGridMenuOpen?: (coords: {
73
- x: number;
74
- y: number;
75
- item?: Record<string, unknown>;
76
- column?: GridColumn<Record<string, unknown>>;
77
- }) => void;
78
- onCellClick?: (item: Item) => void;
79
- onCellHover?: (item: Item) => void;
80
- onCellLeave?: (item: Item) => void;
81
- onCellContextMenu?: (item: Item, x: number, y: number) => void;
82
- onColumnResized?: (col: number, width: number) => void;
83
- onGridResize?: (width: number, height: number) => void;
84
- onGridEnter?: () => void;
85
- onkeydown?: (e: KeyboardEvent) => void;
86
- children?: Snippet;
87
- }
88
-
89
- const {
90
- columns,
91
- rows,
92
- getCellContent,
93
- width,
94
- height,
95
- mergedTheme,
96
- isDarkMode: isDarkModeProp = false,
97
- headerHeight = 36,
98
- rowHeight = 34,
99
- readonly = false,
100
- fixedColumns = 0,
101
- resizableColumns = true,
102
- rowMarkers = "none",
103
- showSearch = false,
104
- searchValue = "",
105
- isEditing,
106
- editingCell,
107
- onBeginEdit,
108
- onEditComplete,
109
- onCancelEdit,
110
- onRowToggle,
111
- onVisibleRangeChange,
112
- onSelectionChange,
113
- onCellDblClick,
114
- onRowClick,
115
- onRowContextMenu,
116
- onColumnMoved,
117
- onCellEdited: _onCellEdited,
118
- onDelete: _onDelete,
119
- onRowAppended: _onRowAppended,
120
- onSelectionCleared: _onSelectionCleared,
121
- sortOrder,
122
- onSortOrderChange,
123
- onHeaderContextMenu,
124
- onSearchValueChange,
125
- onSearchClose,
126
- onGridMenuOpen,
127
- onCellClick,
128
- onCellHover,
129
- onCellLeave,
130
- onCellContextMenu,
131
- onColumnResized,
132
- onGridResize,
133
- onGridEnter,
134
- onkeydown,
135
- children,
136
- }: Props = $props();
137
-
138
- // ── Internal state ───────────────────────────────────────────────────────────
139
-
140
- let scrollX = $state(0);
141
- let scrollY = $state(0);
142
- let currentSelection = $state<Selection>({ type: "none" });
143
- let focusedCell = $state<Item | null>(null);
144
- let isDragging = $state(false);
145
- let dragStart = $state<Item | null>(null);
146
- let resizingColumn = $state<number | null>(null);
147
- let resizeWidth = $state<number | null>(null);
148
- let hoverResizeCol = $state<number | null>(null);
149
- let draggingHeaderCol = $state<number | null>(null);
150
- let dragHeaderCurrentX = $state(0);
151
- let announcement = $state("");
152
- let hasFocus = $state(false);
153
- const gridId = `gridler-${Math.random().toString(36).slice(2, 10)}`;
154
-
155
- let hoveredCell = $state<Item | null>(null);
156
- let isDraggingVScrollbar = $state(false);
157
- let isDraggingHScrollbar = $state(false);
158
- let vScrollDragStartY = $state(0);
159
- let hScrollDragStartX = $state(0);
160
- let vScrollDragStartScroll = $state(0);
161
- let hScrollDragStartScroll = $state(0);
162
- let isHoveringVScrollbar = $state(false);
163
- let isHoveringHScrollbar = $state(false);
164
- let hoverMenuButton = $state(false);
165
- let pendingGridMenuOpen = $state(false);
166
- let hoverSortCol = $state<number | null>(null);
167
- let touchStartClientX = $state(0);
168
- let touchStartClientY = $state(0);
169
- let touchStartScrollX = $state(0);
170
- let touchStartScrollY = $state(0);
171
- let isTouchScrolling = $state(false);
172
-
173
- let containerRef: HTMLDivElement | undefined = $state();
174
- let canvasRef: HTMLCanvasElement | undefined = $state();
175
- let scrollRef: HTMLDivElement | undefined = $state();
176
- let ctx: CanvasRenderingContext2D | null = $derived(
177
- canvasRef?.getContext("2d") ?? null,
178
- );
179
-
180
- let containerWidth = $state(0);
181
- let containerHeight = $state(0);
182
-
183
- const canvasCursor = $derived(
184
- resizingColumn !== null || hoverResizeCol !== null
185
- ? "col-resize"
186
- : hoverMenuButton || hoverSortCol !== null
187
- ? "pointer"
188
- : "default",
189
- );
190
-
191
- let drawPending = false;
192
-
193
- const rowMarkerWidth = $derived(
194
- rowMarkers !== "none" || !!onGridMenuOpen ? 40 : 0,
195
- );
196
-
197
- const effectiveColumns: GridlerColumn[] = $derived.by(() => {
198
- const cols = resizingColumn !== null && resizeWidth !== null
199
- ? columns.map((c, i) => i === resizingColumn ? { ...c, width: resizeWidth, grow: 0 } : c)
200
- : columns;
201
- return computeEffectiveColumns(cols, containerWidth, rowMarkerWidth);
202
- });
203
-
204
- const totalWidth = $derived.by(() => {
205
- const cols = resizingColumn !== null && resizeWidth !== null
206
- ? columns.map((c, i) => i === resizingColumn ? { ...c, width: resizeWidth } : c)
207
- : columns;
208
- return cols.reduce((sum, col) => sum + col.width, 0) + rowMarkerWidth;
209
- });
210
-
211
- const totalHeight = $derived(getTotalHeight(rows, rowHeight));
212
-
213
- const visibleRange: VisibleRange = $derived.by(() => {
214
- if (!containerWidth || !containerHeight) {
215
- return { firstCol: 0, lastCol: 0, firstRow: 0, lastRow: 0 };
216
- }
217
- return calculateVisibleRange(
218
- scrollX,
219
- scrollY,
220
- containerWidth,
221
- containerHeight,
222
- effectiveColumns,
223
- rowHeight,
224
- headerHeight,
225
- rows,
226
- );
227
- });
228
-
229
- const containerWidthStyle = $derived(
230
- typeof width === "number" ? `${width}px` : width,
231
- );
232
- const containerHeightStyle = $derived(
233
- typeof height === "number" ? `${height}px` : height,
234
- );
235
- const activeCellDescription = $derived.by(() => {
236
- if (!focusedCell) {
237
- return rows > 0 && columns.length > 0
238
- ? "Use arrow keys to move between cells. On touch devices, swipe to scroll and tap to select."
239
- : "No rows available.";
240
- }
241
-
242
- const [col, row] = focusedCell;
243
- const columnTitle = columns[col]?.title ?? `Column ${col + 1}`;
244
- const cell = getCellContent(focusedCell);
245
- const value = `${cell.displayData ?? ""}`.trim();
246
- return value
247
- ? `Row ${row + 1}, ${columnTitle}, value ${value}`
248
- : `Row ${row + 1}, ${columnTitle}, empty cell`;
249
- });
250
- const selectionDescription = $derived.by(() => {
251
- switch (currentSelection.type) {
252
- case "cell":
253
- return "One cell selected.";
254
- case "range":
255
- return `Range selected from row ${currentSelection.start[1] + 1} to row ${currentSelection.end[1] + 1}.`;
256
- case "row":
257
- return `${currentSelection.rows.length} row${currentSelection.rows.length === 1 ? "" : "s"} selected.`;
258
- case "column":
259
- return `${currentSelection.columns.length} column${currentSelection.columns.length === 1 ? "" : "s"} selected.`;
260
- default:
261
- return "No selection.";
262
- }
263
- });
264
-
265
- // ── Resize observer ───────────────────────────────────────────────────────────
266
-
267
- $effect(() => {
268
- if (!containerRef) return;
269
- const observer = new ResizeObserver((entries) => {
270
- const entry = entries[0];
271
- if (entry) {
272
- containerWidth = entry.contentRect.width;
273
- containerHeight = entry.contentRect.height;
274
- onGridResize?.(entry.contentRect.width, entry.contentRect.height);
275
- }
276
- });
277
- observer.observe(containerRef);
278
- return () => observer.disconnect();
279
- });
280
-
281
- // ── Image load repaint ────────────────────────────────────────────────────────
282
-
283
- $effect(() => {
284
- const handler = () => scheduleDraw();
285
- document.addEventListener(IMAGE_LOADED_EVENT, handler);
286
- return () => document.removeEventListener(IMAGE_LOADED_EVENT, handler);
287
- });
288
-
289
- // ── Document listener cleanup on destroy ─────────────────────────────────────
290
- $effect(() => {
291
- return () => {
292
- document.removeEventListener("mousemove", handleDocumentMouseMove);
293
- document.removeEventListener("mouseup", handleDocumentMouseUp);
294
- };
295
- });
296
-
297
- // ── Render loop ───────────────────────────────────────────────────────────────
298
-
299
- $effect(() => {
300
- // Track reactive dependencies that should trigger a redraw.
301
- const _deps = [visibleRange, searchValue, currentSelection, getCellContent, mergedTheme, isDarkModeProp, sortOrder];
302
- void _deps;
303
- if (ctx && containerWidth > 0 && containerHeight > 0) {
304
- scheduleDraw();
305
- }
306
- });
307
-
308
- $effect(() => {
309
- onVisibleRangeChange?.(visibleRange);
310
- });
311
-
312
- function scheduleDraw() {
313
- if (drawPending) return;
314
- drawPending = true;
315
- requestAnimationFrame(() => {
316
- drawPending = false;
317
- doRenderFrame();
318
- });
319
- }
320
-
321
- function buildRenderContext(): GridlerRenderContext {
322
- return {
323
- containerWidth,
324
- containerHeight,
325
- headerHeight,
326
- rowHeight,
327
- rowMarkerWidth,
328
- fixedColumns,
329
- resizableColumns: resizableColumns ?? true,
330
- effectiveColumns,
331
- rows,
332
- getCellContent,
333
- searchValue: searchValue ?? "",
334
- scrollX,
335
- scrollY,
336
- totalWidth,
337
- totalHeight,
338
- currentSelection,
339
- mergedTheme,
340
- isDarkMode: isDarkModeProp,
341
- rowMarkers: rowMarkers ?? "none",
342
- draggingHeaderCol,
343
- dragHeaderCurrentX,
344
- hoverResizeCol,
345
- isDraggingVScrollbar,
346
- isDraggingHScrollbar,
347
- isHoveringVScrollbar,
348
- isHoveringHScrollbar,
349
- sortOrder,
350
- };
351
- }
352
-
353
- function drawMenuButton() {
354
- if (!ctx || !onGridMenuOpen || rowMarkerWidth <= 0) return;
355
-
356
- // When rowMarkers is 'none', renderRowMarkers skips drawing the column.
357
- // We need to fill the column background and draw the separator ourselves.
358
- if (rowMarkers === "none") {
359
- ctx.fillStyle = mergedTheme.bgHeader;
360
- ctx.fillRect(0, 0, rowMarkerWidth, containerHeight);
361
- ctx.strokeStyle = mergedTheme.borderColor;
362
- ctx.lineWidth = 1;
363
- ctx.beginPath();
364
- ctx.moveTo(rowMarkerWidth - 0.5, 0);
365
- ctx.lineTo(rowMarkerWidth - 0.5, containerHeight);
366
- ctx.stroke();
367
- }
368
-
369
- // Corner cell hover highlight.
370
- if (hoverMenuButton) {
371
- ctx.fillStyle = mergedTheme.bgHeaderHasFocus;
372
- ctx.fillRect(0, 0, rowMarkerWidth, headerHeight);
373
- }
374
-
375
- // Burger icon (three lines).
376
- const iconW = 14;
377
- const startX = Math.round((rowMarkerWidth - iconW) / 2) + 0.5;
378
- const midY = Math.round(headerHeight / 2) + 0.5;
379
- ctx.strokeStyle = mergedTheme.foreground;
380
- ctx.lineWidth = 1.5;
381
- ctx.lineCap = "round";
382
- ctx.globalAlpha = hoverMenuButton ? 1.0 : 0.5;
383
- ctx.beginPath();
384
- ctx.moveTo(startX, midY - 4);
385
- ctx.lineTo(startX + iconW, midY - 4);
386
- ctx.moveTo(startX, midY);
387
- ctx.lineTo(startX + iconW, midY);
388
- ctx.moveTo(startX, midY + 4);
389
- ctx.lineTo(startX + iconW, midY + 4);
390
- ctx.stroke();
391
- ctx.globalAlpha = 1.0;
392
- }
393
-
394
- function doRenderFrame() {
395
- if (!ctx || !canvasRef) return;
396
-
397
- const dpr = window.devicePixelRatio || 1;
398
- const canvasWidth = containerWidth;
399
- const canvasHeight = containerHeight;
400
-
401
- canvasRef.width = canvasWidth * dpr;
402
- canvasRef.height = canvasHeight * dpr;
403
- canvasRef.style.width = `${canvasWidth}px`;
404
- canvasRef.style.height = `${canvasHeight}px`;
405
-
406
- const rc = buildRenderContext();
407
- renderFrame(ctx, rc, visibleRange);
408
- drawMenuButton();
409
- }
410
-
411
- // ── Exposed methods ───────────────────────────────────────────────────────────
412
-
413
- export function scrollCellIntoView(item: Item): void {
414
- if (!scrollRef) return;
415
- const scrollTarget = scrollToCell(
416
- item,
417
- effectiveColumns,
418
- rowHeight,
419
- headerHeight,
420
- containerWidth,
421
- containerHeight,
422
- );
423
- if (scrollTarget) {
424
- scrollRef.scrollLeft = scrollTarget.scrollX;
425
- scrollRef.scrollTop = scrollTarget.scrollY;
426
- }
427
- }
428
-
429
- export function getFocusedCell(): Item | null {
430
- return focusedCell;
431
- }
432
-
433
- export function setFocusedCell(cell: Item | null): void {
434
- focusedCell = cell;
435
- }
436
-
437
- export function setCurrentSelection(sel: Selection): void {
438
- currentSelection = sel;
439
- scheduleDraw();
440
- }
441
-
442
- export function getCurrentSelection(): Selection {
443
- return currentSelection;
444
- }
445
-
446
- export function focus(): void {
447
- containerRef?.focus();
448
- }
449
-
450
- // ── Scroll handler ────────────────────────────────────────────────────────────
451
-
452
- function handleScroll(e: Event) {
453
- const target = e.target as HTMLElement;
454
- scrollX = target.scrollLeft;
455
- scrollY = target.scrollTop;
456
- }
457
-
458
- // ── Header context menu ───────────────────────────────────────────────────────
459
-
460
- function handleContextMenu(e: MouseEvent) {
461
- if (e.offsetY < headerHeight && e.offsetX < rowMarkerWidth) return;
462
- const fixedBoundaryX =
463
- fixedColumns > 0
464
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
465
- : 0;
466
- const localX = e.offsetX - rowMarkerWidth;
467
- const effectiveScrollX =
468
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
469
-
470
- if (e.offsetY < headerHeight) {
471
- if (!onHeaderContextMenu) return;
472
- const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
473
- if (col >= 0 && col < columns.length) {
474
- e.preventDefault();
475
- onHeaderContextMenu(col, e.clientX, e.clientY);
476
- }
477
- return;
478
- }
479
-
480
- const row = getRowFromOffsetY(e.offsetY);
481
- if (row !== null) {
482
- e.preventDefault();
483
- onRowContextMenu?.(row, e.clientX, e.clientY);
484
- }
485
-
486
- if (e.offsetX < rowMarkerWidth) return;
487
-
488
- if (onCellContextMenu) {
489
- const cell = getCellFromPoint(
490
- localX,
491
- e.offsetY,
492
- effectiveScrollX,
493
- scrollY,
494
- effectiveColumns,
495
- rowHeight,
496
- headerHeight,
497
- );
498
- if (cell) {
499
- e.preventDefault();
500
- onCellContextMenu(cell, e.clientX, e.clientY);
501
- }
502
- }
503
- }
504
-
505
- // ── Sort helpers ──────────────────────────────────────────────────────────────
506
-
507
- function toggleSort(col: number, multi: boolean = false) {
508
- const column = columns[col];
509
- if (!column || !onSortOrderChange || column.disableSort === true) return;
510
- const current = sortOrder?.[column.id] ?? "none";
511
- const next =
512
- current === "none" ? "asc" : current === "asc" ? "desc" : "none";
513
- if (multi) {
514
- onSortOrderChange({ ...sortOrder, [column.id]: next });
515
- } else {
516
- onSortOrderChange({ [column.id]: next });
517
- }
518
- }
519
-
520
- // ── Mouse handlers ────────────────────────────────────────────────────────────
521
-
522
- function getRowFromOffsetY(offsetY: number): number | null {
523
- if (offsetY < headerHeight) return null;
524
- const row = Math.floor((offsetY - headerHeight + scrollY) / rowHeight);
525
- return row >= 0 && row < rows ? row : null;
526
- }
527
-
528
- function handleMouseDown(e: MouseEvent) {
529
- if (e.button !== 0) return;
530
- pendingGridMenuOpen = false;
531
- containerRef?.focus();
532
-
533
- const sbGeo = getScrollbarGeometry(buildRenderContext());
534
- if (
535
- sbGeo.needsVScroll &&
536
- e.offsetX >= sbGeo.vTrackX &&
537
- e.offsetY >= sbGeo.vTrackY &&
538
- e.offsetY < sbGeo.vTrackY + sbGeo.vTrackH
539
- ) {
540
- isDraggingVScrollbar = true;
541
- vScrollDragStartY = e.clientY;
542
- vScrollDragStartScroll = scrollY;
543
- document.addEventListener("mousemove", handleDocumentMouseMove);
544
- document.addEventListener("mouseup", handleDocumentMouseUp);
545
- return;
546
- }
547
- if (
548
- sbGeo.needsHScroll &&
549
- e.offsetY >= sbGeo.hTrackY &&
550
- e.offsetX >= sbGeo.hTrackX &&
551
- e.offsetX < sbGeo.hTrackX + sbGeo.hTrackW
552
- ) {
553
- isDraggingHScrollbar = true;
554
- hScrollDragStartX = e.clientX;
555
- hScrollDragStartScroll = scrollX;
556
- document.addEventListener("mousemove", handleDocumentMouseMove);
557
- document.addEventListener("mouseup", handleDocumentMouseUp);
558
- return;
559
- }
560
-
561
- // Menu button click — handled before readonly check so it always fires.
562
- if (
563
- onGridMenuOpen &&
564
- rowMarkerWidth > 0 &&
565
- e.offsetX < rowMarkerWidth &&
566
- e.offsetY < headerHeight
567
- ) {
568
- pendingGridMenuOpen = true;
569
- return;
570
- }
571
-
572
- const fixedBoundaryX =
573
- fixedColumns > 0
574
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
575
- : 0;
576
- const localX = e.offsetX - rowMarkerWidth;
577
- const effectiveScrollX =
578
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
579
-
580
- // Header interactions — sort always active; resize/reorder/selection gated by readonly
581
- if (e.offsetY < headerHeight) {
582
- if (!readonly && resizableColumns) {
583
- const resizeCol = getResizeHandle(
584
- localX,
585
- effectiveColumns,
586
- effectiveScrollX,
587
- );
588
- if (resizeCol !== null) {
589
- resizingColumn = resizeCol;
590
- return;
591
- }
592
- }
593
-
594
- // Column drag reorder (only if onColumnMoved provided and not readonly)
595
- if (!readonly && onColumnMoved) {
596
- const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
597
- draggingHeaderCol = col;
598
- dragHeaderCurrentX = e.offsetX;
599
- return;
600
- }
601
-
602
- // Column header click → sort toggle (always) + column selection (when not readonly)
603
- const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
604
- if (col >= 0 && col < columns.length) {
605
- toggleSort(col, e.ctrlKey || e.metaKey || e.shiftKey);
606
- if (!readonly) {
607
- if (e.shiftKey && currentSelection.type === "column") {
608
- const cols = [...currentSelection.columns];
609
- if (!cols.includes(col)) cols.push(col);
610
- currentSelection = { type: "column", columns: cols };
611
- } else {
612
- currentSelection = { type: "column", columns: [col] };
613
- }
614
- onSelectionChange?.(currentSelection);
615
- announcement = `Column ${columns[col].title} selected`;
616
- }
617
- }
618
- return;
619
- }
620
-
621
- const row = getRowFromOffsetY(e.offsetY);
622
- if (row !== null) {
623
- onRowClick?.(row);
624
- }
625
-
626
- if (readonly) return;
627
-
628
- const cell = getCellFromPoint(
629
- localX,
630
- e.offsetY,
631
- effectiveScrollX,
632
- scrollY,
633
- effectiveColumns,
634
- rowHeight,
635
- headerHeight,
636
- );
637
-
638
- if (rowMarkers !== "none" && e.offsetX < rowMarkerWidth) {
639
- if (e.offsetY >= headerHeight) {
640
- const row = Math.floor(
641
- (e.offsetY - headerHeight + scrollY) / rowHeight,
642
- );
643
- if (row >= 0 && row < rows) {
644
- onRowToggle?.(row);
645
- }
646
- }
647
- return;
648
- }
649
-
650
- if (cell) {
651
- onCellClick?.(cell);
652
- if (e.shiftKey && focusedCell) {
653
- const newSelection: Selection = {
654
- type: "range",
655
- start: focusedCell,
656
- end: cell,
657
- };
658
- currentSelection = newSelection;
659
- onSelectionChange?.(newSelection);
660
- } else {
661
- focusedCell = cell;
662
- dragStart = cell;
663
- isDragging = true;
664
- currentSelection = { type: "cell", item: cell };
665
- onSelectionChange?.(currentSelection);
666
- announcement = `Row ${cell[1] + 1}, column ${columns[cell[0]]?.title ?? cell[0] + 1}`;
667
- }
668
- }
669
- }
670
-
671
- function handleDblClick(e: MouseEvent) {
672
- if (e.offsetY < headerHeight) return;
673
- if (rowMarkers !== "none" && e.offsetX < rowMarkerWidth) return;
674
-
675
- const fixedBoundaryX =
676
- fixedColumns > 0
677
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
678
- : 0;
679
- const localX = e.offsetX - rowMarkerWidth;
680
- const effectiveScrollX =
681
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
682
-
683
- const cell = getCellFromPoint(
684
- localX,
685
- e.offsetY,
686
- effectiveScrollX,
687
- scrollY,
688
- effectiveColumns,
689
- rowHeight,
690
- headerHeight,
691
- );
692
- if (cell) {
693
- onCellDblClick?.(cell, getCellContent(cell));
694
- if (!readonly) {
695
- onBeginEdit(cell);
696
- }
697
- }
698
- }
699
-
700
- function handleDocumentMouseMove(e: MouseEvent) {
701
- if (isDraggingVScrollbar) {
702
- const geo = getScrollbarGeometry(buildRenderContext());
703
- const trackRange = geo.vTrackH - geo.vThumbH;
704
- if (trackRange > 0 && scrollRef) {
705
- const newScroll =
706
- vScrollDragStartScroll +
707
- ((e.clientY - vScrollDragStartY) / trackRange) * geo.maxScrollY;
708
- scrollRef.scrollTop = Math.max(0, Math.min(geo.maxScrollY, newScroll));
709
- }
710
- } else if (isDraggingHScrollbar) {
711
- const geo = getScrollbarGeometry(buildRenderContext());
712
- const trackRange = geo.hTrackW - geo.hThumbW;
713
- if (trackRange > 0 && scrollRef) {
714
- const newScroll =
715
- hScrollDragStartScroll +
716
- ((e.clientX - hScrollDragStartX) / trackRange) * geo.maxScrollX;
717
- scrollRef.scrollLeft = Math.max(0, Math.min(geo.maxScrollX, newScroll));
718
- }
719
- }
720
- }
721
-
722
- function handleDocumentMouseUp() {
723
- isDraggingVScrollbar = false;
724
- isDraggingHScrollbar = false;
725
- scheduleDraw();
726
- document.removeEventListener("mousemove", handleDocumentMouseMove);
727
- document.removeEventListener("mouseup", handleDocumentMouseUp);
728
- }
729
-
730
- function handleMouseMove(e: MouseEvent) {
731
- if (isDraggingVScrollbar || isDraggingHScrollbar) return;
732
-
733
- // Menu button hover.
734
- if (onGridMenuOpen && rowMarkerWidth > 0) {
735
- const isOver = e.offsetX < rowMarkerWidth && e.offsetY < headerHeight;
736
- if (isOver !== hoverMenuButton) {
737
- hoverMenuButton = isOver;
738
- scheduleDraw();
739
- }
740
- }
741
-
742
- const sbGeo = getScrollbarGeometry(buildRenderContext());
743
- const newHoverV =
744
- sbGeo.needsVScroll &&
745
- e.offsetX >= sbGeo.vTrackX &&
746
- e.offsetY >= sbGeo.vTrackY &&
747
- e.offsetY < sbGeo.vTrackY + sbGeo.vTrackH;
748
- const newHoverH =
749
- sbGeo.needsHScroll &&
750
- e.offsetY >= sbGeo.hTrackY &&
751
- e.offsetX >= sbGeo.hTrackX &&
752
- e.offsetX < sbGeo.hTrackX + sbGeo.hTrackW;
753
- if (
754
- newHoverV !== isHoveringVScrollbar ||
755
- newHoverH !== isHoveringHScrollbar
756
- ) {
757
- isHoveringVScrollbar = newHoverV;
758
- isHoveringHScrollbar = newHoverH;
759
- scheduleDraw();
760
- }
761
-
762
- // Sortable header hover (for pointer cursor)
763
- if (
764
- onSortOrderChange &&
765
- e.offsetY < headerHeight &&
766
- e.offsetX >= rowMarkerWidth
767
- ) {
768
- const fixedBoundaryX =
769
- fixedColumns > 0
770
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
771
- : 0;
772
- const localX = e.offsetX - rowMarkerWidth;
773
- const effectiveScrollX =
774
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX
775
- ? 0
776
- : scrollX;
777
- const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
778
- const isSortable =
779
- col >= 0 && col < columns.length && columns[col].disableSort !== true;
780
- const newHoverSort = isSortable ? col : null;
781
- if (newHoverSort !== hoverSortCol) {
782
- hoverSortCol = newHoverSort;
783
- }
784
- } else if (hoverSortCol !== null) {
785
- hoverSortCol = null;
786
- }
787
-
788
- if (
789
- resizableColumns &&
790
- resizingColumn === null &&
791
- e.offsetY < headerHeight
792
- ) {
793
- const fixedBoundaryX =
794
- fixedColumns > 0
795
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
796
- : 0;
797
- const localX = e.offsetX - rowMarkerWidth;
798
- const effectiveScrollX =
799
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX
800
- ? 0
801
- : scrollX;
802
- const resizeCol = getResizeHandle(
803
- localX,
804
- effectiveColumns,
805
- effectiveScrollX,
806
- );
807
- if (resizeCol !== hoverResizeCol) {
808
- hoverResizeCol = resizeCol;
809
- scheduleDraw();
810
- }
811
- } else if (hoverResizeCol !== null) {
812
- hoverResizeCol = null;
813
- scheduleDraw();
814
- }
815
-
816
- if (resizingColumn !== null) {
817
- const baseX =
818
- getColumnX(effectiveColumns, resizingColumn) + rowMarkerWidth;
819
- const scrollXForResize =
820
- fixedColumns > 0 && resizingColumn < fixedColumns ? 0 : scrollX;
821
- const newWidth = Math.max(50, e.offsetX - baseX + scrollXForResize);
822
- resizeWidth = newWidth;
823
- scheduleDraw();
824
- return;
825
- }
826
-
827
- if (draggingHeaderCol !== null) {
828
- dragHeaderCurrentX = e.offsetX;
829
- scheduleDraw();
830
- return;
831
- }
832
-
833
- // Hover cell tracking
834
- if (e.offsetY >= headerHeight && e.offsetX >= rowMarkerWidth) {
835
- const hoverFixedBoundaryX =
836
- fixedColumns > 0
837
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
838
- : 0;
839
- const hoverLocalX = e.offsetX - rowMarkerWidth;
840
- const hoverScrollX =
841
- fixedColumns > 0 && hoverLocalX >= 0 && hoverLocalX < hoverFixedBoundaryX
842
- ? 0
843
- : scrollX;
844
- const hoverCell = getCellFromPoint(
845
- hoverLocalX,
846
- e.offsetY,
847
- hoverScrollX,
848
- scrollY,
849
- effectiveColumns,
850
- rowHeight,
851
- headerHeight,
852
- );
853
- if (hoverCell?.[0] !== hoveredCell?.[0] || hoverCell?.[1] !== hoveredCell?.[1]) {
854
- if (hoveredCell) onCellLeave?.(hoveredCell);
855
- hoveredCell = hoverCell;
856
- if (hoverCell) onCellHover?.(hoverCell);
857
- }
858
- } else if (hoveredCell !== null) {
859
- onCellLeave?.(hoveredCell);
860
- hoveredCell = null;
861
- }
862
-
863
- if (!isDragging || !dragStart) return;
864
-
865
- const fixedBoundaryX =
866
- fixedColumns > 0
867
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
868
- : 0;
869
- const localX = e.offsetX - rowMarkerWidth;
870
- const effectiveScrollX =
871
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
872
-
873
- const cell = getCellFromPoint(
874
- localX,
875
- e.offsetY,
876
- effectiveScrollX,
877
- scrollY,
878
- effectiveColumns,
879
- rowHeight,
880
- headerHeight,
881
- );
882
-
883
- if (cell && (cell[0] !== dragStart[0] || cell[1] !== dragStart[1])) {
884
- currentSelection = { type: "range", start: dragStart, end: cell };
885
- onSelectionChange?.(currentSelection);
886
- }
887
- }
888
-
889
- function handleMouseUp(e: MouseEvent) {
890
- if (
891
- pendingGridMenuOpen &&
892
- onGridMenuOpen &&
893
- rowMarkerWidth > 0 &&
894
- e.offsetX < rowMarkerWidth &&
895
- e.offsetY < headerHeight
896
- ) {
897
- onGridMenuOpen({ x: e.clientX, y: e.clientY });
898
- }
899
- pendingGridMenuOpen = false;
900
-
901
- if (draggingHeaderCol !== null && onColumnMoved) {
902
- const fixedBoundaryX =
903
- fixedColumns > 0
904
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
905
- : 0;
906
- const localX = e.offsetX - rowMarkerWidth;
907
- const effectiveScrollX =
908
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX
909
- ? 0
910
- : scrollX;
911
-
912
- const targetCol = getColumnFromX(
913
- localX + effectiveScrollX,
914
- effectiveColumns,
915
- );
916
- if (targetCol !== draggingHeaderCol) {
917
- onColumnMoved(draggingHeaderCol, targetCol);
918
- } else {
919
- // No actual drag movement — treat as a header click and toggle sort
920
- toggleSort(draggingHeaderCol, e.ctrlKey || e.metaKey || e.shiftKey);
921
- }
922
- draggingHeaderCol = null;
923
- scheduleDraw();
924
- }
925
-
926
- if (resizingColumn !== null && resizeWidth !== null) {
927
- onColumnResized?.(resizingColumn, resizeWidth);
928
- }
929
- isDragging = false;
930
- dragStart = null;
931
- resizingColumn = null;
932
- resizeWidth = null;
933
- }
934
-
935
- function handleMouseLeave(e: MouseEvent) {
936
- isHoveringVScrollbar = false;
937
- isHoveringHScrollbar = false;
938
- hoverResizeCol = null;
939
- hoverMenuButton = false;
940
- hoverSortCol = null;
941
- pendingGridMenuOpen = false;
942
- if (hoveredCell) {
943
- onCellLeave?.(hoveredCell);
944
- hoveredCell = null;
945
- }
946
- if (!isDraggingVScrollbar && !isDraggingHScrollbar) {
947
- handleMouseUp(e);
948
- } else {
949
- scheduleDraw();
950
- }
951
- }
952
-
953
- function handleWheel(e: WheelEvent) {
954
- if (scrollRef) {
955
- e.preventDefault();
956
- scrollRef.scrollLeft += e.deltaX;
957
- scrollRef.scrollTop += e.deltaY;
958
- }
959
- }
960
-
961
- // ── Focus handlers ────────────────────────────────────────────────────────────
962
-
963
- export function handleFocusIn() {
964
- hasFocus = true;
965
-
966
- if (!focusedCell && rows > 0 && columns.length > 0) {
967
- if (currentSelection.type === "cell") {
968
- focusedCell = currentSelection.item;
969
- } else if (currentSelection.type === "range") {
970
- focusedCell = currentSelection.start;
971
- } else if (currentSelection.type === "row") {
972
- focusedCell = [0, currentSelection.rows[0] ?? 0];
973
- } else if (currentSelection.type === "column") {
974
- focusedCell = [currentSelection.columns[0] ?? 0, 0];
975
- } else {
976
- focusedCell = [0, 0];
977
- currentSelection = { type: "cell", item: focusedCell };
978
- onSelectionChange?.(currentSelection);
979
- }
980
- }
981
- }
982
-
983
- export function handleFocusOut(e: FocusEvent) {
984
- const next = e.relatedTarget as Node | null;
985
- if (next && containerRef?.contains(next)) return;
986
- hasFocus = false;
987
- }
988
-
989
- export function getHasFocus(): boolean {
990
- return hasFocus;
991
- }
992
-
993
- export function getAnnouncement(): string {
994
- return announcement;
995
- }
996
-
997
- export function setAnnouncement(msg: string): void {
998
- announcement = msg;
999
- }
1000
-
1001
- function getLocalPointFromClient(clientX: number, clientY: number) {
1002
- const rect = canvasRef?.getBoundingClientRect();
1003
- if (!rect) return null;
1004
-
1005
- return {
1006
- offsetX: clientX - rect.left,
1007
- offsetY: clientY - rect.top,
1008
- };
1009
- }
1010
-
1011
- function handleTouchTap(offsetX: number, offsetY: number) {
1012
- containerRef?.focus();
1013
-
1014
- if (
1015
- onGridMenuOpen &&
1016
- rowMarkerWidth > 0 &&
1017
- offsetX < rowMarkerWidth &&
1018
- offsetY < headerHeight
1019
- ) {
1020
- const rect = canvasRef?.getBoundingClientRect();
1021
- onGridMenuOpen({
1022
- x: (rect?.left ?? 0) + offsetX,
1023
- y: (rect?.top ?? 0) + offsetY,
1024
- });
1025
- return;
1026
- }
1027
-
1028
- const fixedBoundaryX =
1029
- fixedColumns > 0
1030
- ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length))
1031
- : 0;
1032
- const localX = offsetX - rowMarkerWidth;
1033
- const effectiveScrollX =
1034
- fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
1035
-
1036
- if (offsetY < headerHeight) {
1037
- const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
1038
- if (col >= 0 && col < columns.length) {
1039
- toggleSort(col, false);
1040
- if (!readonly) {
1041
- currentSelection = { type: "column", columns: [col] };
1042
- onSelectionChange?.(currentSelection);
1043
- announcement = `Column ${columns[col].title} selected`;
1044
- }
1045
- }
1046
- return;
1047
- }
1048
-
1049
- if (rowMarkers !== "none" && offsetX < rowMarkerWidth) {
1050
- const row = getRowFromOffsetY(offsetY);
1051
- if (row !== null && row >= 0 && row < rows) {
1052
- onRowToggle?.(row);
1053
- }
1054
- return;
1055
- }
1056
-
1057
- const cell = getCellFromPoint(
1058
- localX,
1059
- offsetY,
1060
- effectiveScrollX,
1061
- scrollY,
1062
- effectiveColumns,
1063
- rowHeight,
1064
- headerHeight,
1065
- );
1066
-
1067
- if (cell) {
1068
- focusedCell = cell;
1069
- currentSelection = { type: "cell", item: cell };
1070
- onSelectionChange?.(currentSelection);
1071
- announcement = `Row ${cell[1] + 1}, column ${columns[cell[0]]?.title ?? cell[0] + 1}`;
1072
- }
1073
- }
1074
-
1075
- function handleTouchStart(e: TouchEvent) {
1076
- if (e.touches.length !== 1) return;
1077
-
1078
- const touch = e.touches[0];
1079
- touchStartClientX = touch.clientX;
1080
- touchStartClientY = touch.clientY;
1081
- touchStartScrollX = scrollRef?.scrollLeft ?? 0;
1082
- touchStartScrollY = scrollRef?.scrollTop ?? 0;
1083
- isTouchScrolling = false;
1084
- }
1085
-
1086
- function handleTouchMove(e: TouchEvent) {
1087
- if (e.touches.length !== 1 || !scrollRef) return;
1088
-
1089
- const touch = e.touches[0];
1090
- const deltaX = touch.clientX - touchStartClientX;
1091
- const deltaY = touch.clientY - touchStartClientY;
1092
-
1093
- if (!isTouchScrolling && (Math.abs(deltaX) > 8 || Math.abs(deltaY) > 8)) {
1094
- isTouchScrolling = true;
1095
- }
1096
-
1097
- if (!isTouchScrolling) return;
1098
-
1099
- e.preventDefault();
1100
- scrollRef.scrollLeft = touchStartScrollX - deltaX;
1101
- scrollRef.scrollTop = touchStartScrollY - deltaY;
1102
- }
1103
-
1104
- function handleTouchEnd(e: TouchEvent) {
1105
- const touch = e.changedTouches[0];
1106
- if (!touch) return;
1107
-
1108
- const point = getLocalPointFromClient(touch.clientX, touch.clientY);
1109
- const wasScrolling = isTouchScrolling;
1110
- isTouchScrolling = false;
1111
-
1112
- if (!point || wasScrolling) return;
1113
-
1114
- handleTouchTap(point.offsetX, point.offsetY);
1115
- }
1
+ <script lang="ts">import { calculateVisibleRange, getColumnX, getTotalHeight, getCellFromPoint, scrollToCell, getResizeHandle, getColumnFromX } from "../utils/scrolling";
2
+ import { computeEffectiveColumns } from "../utils/columns";
3
+ import { renderFrame, getScrollbarGeometry } from "../utils/canvasRender";
4
+ import { IMAGE_LOADED_EVENT } from "../renderers/ImageRenderer";
5
+ import GridlerEditor from "./GridlerEditor.svelte";
6
+ import GridlerSearch from "./GridlerSearch.svelte";
7
+ const { columns, rows, getCellContent, width, height, mergedTheme, isDarkMode: isDarkModeProp = false, headerHeight = 36, rowHeight = 34, readonly = false, fixedColumns = 0, resizableColumns = true, rowMarkers = "none", showSearch = false, searchValue = "", isEditing, editingCell, onBeginEdit, onEditComplete, onCancelEdit, onRowToggle, onVisibleRangeChange, onSelectionChange, onCellDblClick, onRowClick, onRowContextMenu, onColumnMoved, onCellEdited: _onCellEdited, onDelete: _onDelete, onRowAppended: _onRowAppended, onSelectionCleared: _onSelectionCleared, sortOrder, onSortOrderChange, onHeaderContextMenu, onSearchValueChange, onSearchClose, onGridMenuOpen, onCellClick, onCellHover, onCellLeave, onCellContextMenu, onColumnResized, onGridResize, onGridEnter, onkeydown, children } = $props();
8
+ // ── Internal state ───────────────────────────────────────────────────────────
9
+ let scrollX = $state(0);
10
+ let scrollY = $state(0);
11
+ let currentSelection = $state({ type: "none" });
12
+ let focusedCell = $state(null);
13
+ let isDragging = $state(false);
14
+ let dragStart = $state(null);
15
+ let resizingColumn = $state(null);
16
+ let resizeWidth = $state(null);
17
+ let hoverResizeCol = $state(null);
18
+ let draggingHeaderCol = $state(null);
19
+ let dragHeaderCurrentX = $state(0);
20
+ let announcement = $state("");
21
+ let hasFocus = $state(false);
22
+ const gridId = `gridler-${Math.random().toString(36).slice(2, 10)}`;
23
+ let hoveredCell = $state(null);
24
+ let isDraggingVScrollbar = $state(false);
25
+ let isDraggingHScrollbar = $state(false);
26
+ let vScrollDragStartY = $state(0);
27
+ let hScrollDragStartX = $state(0);
28
+ let vScrollDragStartScroll = $state(0);
29
+ let hScrollDragStartScroll = $state(0);
30
+ let isHoveringVScrollbar = $state(false);
31
+ let isHoveringHScrollbar = $state(false);
32
+ let hoverMenuButton = $state(false);
33
+ let pendingGridMenuOpen = $state(false);
34
+ let hoverSortCol = $state(null);
35
+ let touchStartClientX = $state(0);
36
+ let touchStartClientY = $state(0);
37
+ let touchStartScrollX = $state(0);
38
+ let touchStartScrollY = $state(0);
39
+ let isTouchScrolling = $state(false);
40
+ let containerRef = $state();
41
+ let canvasRef = $state();
42
+ let scrollRef = $state();
43
+ let ctx = $derived(canvasRef?.getContext("2d") ?? null);
44
+ let containerWidth = $state(0);
45
+ let containerHeight = $state(0);
46
+ const canvasCursor = $derived(resizingColumn !== null || hoverResizeCol !== null ? "col-resize" : hoverMenuButton || hoverSortCol !== null ? "pointer" : "default");
47
+ let drawPending = false;
48
+ const rowMarkerWidth = $derived(rowMarkers !== "none" || !!onGridMenuOpen ? 40 : 0);
49
+ const effectiveColumns = $derived.by(() => {
50
+ const cols = resizingColumn !== null && resizeWidth !== null ? columns.map((c, i) => i === resizingColumn ? {
51
+ ...c,
52
+ width: resizeWidth,
53
+ grow: 0
54
+ } : c) : columns;
55
+ return computeEffectiveColumns(cols, containerWidth, rowMarkerWidth);
56
+ });
57
+ const totalWidth = $derived.by(() => {
58
+ const cols = resizingColumn !== null && resizeWidth !== null ? columns.map((c, i) => i === resizingColumn ? {
59
+ ...c,
60
+ width: resizeWidth
61
+ } : c) : columns;
62
+ return cols.reduce((sum, col) => sum + col.width, 0) + rowMarkerWidth;
63
+ });
64
+ const totalHeight = $derived(getTotalHeight(rows, rowHeight));
65
+ const visibleRange = $derived.by(() => {
66
+ if (!containerWidth || !containerHeight) {
67
+ return {
68
+ firstCol: 0,
69
+ lastCol: 0,
70
+ firstRow: 0,
71
+ lastRow: 0
72
+ };
73
+ }
74
+ return calculateVisibleRange(scrollX, scrollY, containerWidth, containerHeight, effectiveColumns, rowHeight, headerHeight, rows);
75
+ });
76
+ const containerWidthStyle = $derived(typeof width === "number" ? `${width}px` : width);
77
+ const containerHeightStyle = $derived(typeof height === "number" ? `${height}px` : height);
78
+ const activeCellDescription = $derived.by(() => {
79
+ if (!focusedCell) {
80
+ return rows > 0 && columns.length > 0 ? "Use arrow keys to move between cells. On touch devices, swipe to scroll and tap to select." : "No rows available.";
81
+ }
82
+ const [col, row] = focusedCell;
83
+ const columnTitle = columns[col]?.title ?? `Column ${col + 1}`;
84
+ const cell = getCellContent(focusedCell);
85
+ const value = `${cell.displayData ?? ""}`.trim();
86
+ return value ? `Row ${row + 1}, ${columnTitle}, value ${value}` : `Row ${row + 1}, ${columnTitle}, empty cell`;
87
+ });
88
+ const selectionDescription = $derived.by(() => {
89
+ switch (currentSelection.type) {
90
+ case "cell": return "One cell selected.";
91
+ case "range": return `Range selected from row ${currentSelection.start[1] + 1} to row ${currentSelection.end[1] + 1}.`;
92
+ case "row": return `${currentSelection.rows.length} row${currentSelection.rows.length === 1 ? "" : "s"} selected.`;
93
+ case "column": return `${currentSelection.columns.length} column${currentSelection.columns.length === 1 ? "" : "s"} selected.`;
94
+ default: return "No selection.";
95
+ }
96
+ });
97
+ // ── Resize observer ───────────────────────────────────────────────────────────
98
+ $effect(() => {
99
+ if (!containerRef) return;
100
+ const observer = new ResizeObserver((entries) => {
101
+ const entry = entries[0];
102
+ if (entry) {
103
+ containerWidth = entry.contentRect.width;
104
+ containerHeight = entry.contentRect.height;
105
+ onGridResize?.(entry.contentRect.width, entry.contentRect.height);
106
+ }
107
+ });
108
+ observer.observe(containerRef);
109
+ return () => observer.disconnect();
110
+ });
111
+ // ── Image load repaint ────────────────────────────────────────────────────────
112
+ $effect(() => {
113
+ const handler = () => scheduleDraw();
114
+ document.addEventListener(IMAGE_LOADED_EVENT, handler);
115
+ return () => document.removeEventListener(IMAGE_LOADED_EVENT, handler);
116
+ });
117
+ // ── Document listener cleanup on destroy ─────────────────────────────────────
118
+ $effect(() => {
119
+ return () => {
120
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
121
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
122
+ };
123
+ });
124
+ // ── Render loop ───────────────────────────────────────────────────────────────
125
+ $effect(() => {
126
+ // Track reactive dependencies that should trigger a redraw.
127
+ const _deps = [
128
+ visibleRange,
129
+ searchValue,
130
+ currentSelection,
131
+ getCellContent,
132
+ mergedTheme,
133
+ isDarkModeProp,
134
+ sortOrder
135
+ ];
136
+ void _deps;
137
+ if (ctx && containerWidth > 0 && containerHeight > 0) {
138
+ scheduleDraw();
139
+ }
140
+ });
141
+ $effect(() => {
142
+ onVisibleRangeChange?.(visibleRange);
143
+ });
144
+ function scheduleDraw() {
145
+ if (drawPending) return;
146
+ drawPending = true;
147
+ requestAnimationFrame(() => {
148
+ drawPending = false;
149
+ doRenderFrame();
150
+ });
151
+ }
152
+ function buildRenderContext() {
153
+ return {
154
+ containerWidth,
155
+ containerHeight,
156
+ headerHeight,
157
+ rowHeight,
158
+ rowMarkerWidth,
159
+ fixedColumns,
160
+ resizableColumns: resizableColumns ?? true,
161
+ effectiveColumns,
162
+ rows,
163
+ getCellContent,
164
+ searchValue: searchValue ?? "",
165
+ scrollX,
166
+ scrollY,
167
+ totalWidth,
168
+ totalHeight,
169
+ currentSelection,
170
+ mergedTheme,
171
+ isDarkMode: isDarkModeProp,
172
+ rowMarkers: rowMarkers ?? "none",
173
+ draggingHeaderCol,
174
+ dragHeaderCurrentX,
175
+ hoverResizeCol,
176
+ isDraggingVScrollbar,
177
+ isDraggingHScrollbar,
178
+ isHoveringVScrollbar,
179
+ isHoveringHScrollbar,
180
+ sortOrder
181
+ };
182
+ }
183
+ function drawMenuButton() {
184
+ if (!ctx || !onGridMenuOpen || rowMarkerWidth <= 0) return;
185
+ // When rowMarkers is 'none', renderRowMarkers skips drawing the column.
186
+ // We need to fill the column background and draw the separator ourselves.
187
+ if (rowMarkers === "none") {
188
+ ctx.fillStyle = mergedTheme.bgHeader;
189
+ ctx.fillRect(0, 0, rowMarkerWidth, containerHeight);
190
+ ctx.strokeStyle = mergedTheme.borderColor;
191
+ ctx.lineWidth = 1;
192
+ ctx.beginPath();
193
+ ctx.moveTo(rowMarkerWidth - .5, 0);
194
+ ctx.lineTo(rowMarkerWidth - .5, containerHeight);
195
+ ctx.stroke();
196
+ }
197
+ // Corner cell hover highlight.
198
+ if (hoverMenuButton) {
199
+ ctx.fillStyle = mergedTheme.bgHeaderHasFocus;
200
+ ctx.fillRect(0, 0, rowMarkerWidth, headerHeight);
201
+ }
202
+ // Burger icon (three lines).
203
+ const iconW = 14;
204
+ const startX = Math.round((rowMarkerWidth - iconW) / 2) + .5;
205
+ const midY = Math.round(headerHeight / 2) + .5;
206
+ ctx.strokeStyle = mergedTheme.foreground;
207
+ ctx.lineWidth = 1.5;
208
+ ctx.lineCap = "round";
209
+ ctx.globalAlpha = hoverMenuButton ? 1 : .5;
210
+ ctx.beginPath();
211
+ ctx.moveTo(startX, midY - 4);
212
+ ctx.lineTo(startX + iconW, midY - 4);
213
+ ctx.moveTo(startX, midY);
214
+ ctx.lineTo(startX + iconW, midY);
215
+ ctx.moveTo(startX, midY + 4);
216
+ ctx.lineTo(startX + iconW, midY + 4);
217
+ ctx.stroke();
218
+ ctx.globalAlpha = 1;
219
+ }
220
+ function doRenderFrame() {
221
+ if (!ctx || !canvasRef) return;
222
+ const dpr = window.devicePixelRatio || 1;
223
+ const canvasWidth = containerWidth;
224
+ const canvasHeight = containerHeight;
225
+ canvasRef.width = canvasWidth * dpr;
226
+ canvasRef.height = canvasHeight * dpr;
227
+ canvasRef.style.width = `${canvasWidth}px`;
228
+ canvasRef.style.height = `${canvasHeight}px`;
229
+ const rc = buildRenderContext();
230
+ renderFrame(ctx, rc, visibleRange);
231
+ drawMenuButton();
232
+ }
233
+ // ── Exposed methods ───────────────────────────────────────────────────────────
234
+ export function scrollCellIntoView(item) {
235
+ if (!scrollRef) return;
236
+ const scrollTarget = scrollToCell(item, effectiveColumns, rowHeight, headerHeight, containerWidth, containerHeight);
237
+ if (scrollTarget) {
238
+ scrollRef.scrollLeft = scrollTarget.scrollX;
239
+ scrollRef.scrollTop = scrollTarget.scrollY;
240
+ }
241
+ }
242
+ export function getFocusedCell() {
243
+ return focusedCell;
244
+ }
245
+ export function setFocusedCell(cell) {
246
+ focusedCell = cell;
247
+ }
248
+ export function setCurrentSelection(sel) {
249
+ currentSelection = sel;
250
+ scheduleDraw();
251
+ }
252
+ export function getCurrentSelection() {
253
+ return currentSelection;
254
+ }
255
+ export function focus() {
256
+ containerRef?.focus();
257
+ }
258
+ // ── Scroll handler ────────────────────────────────────────────────────────────
259
+ function handleScroll(e) {
260
+ const target = e.target;
261
+ scrollX = target.scrollLeft;
262
+ scrollY = target.scrollTop;
263
+ }
264
+ // ── Header context menu ───────────────────────────────────────────────────────
265
+ function handleContextMenu(e) {
266
+ if (e.offsetY < headerHeight && e.offsetX < rowMarkerWidth) return;
267
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
268
+ const localX = e.offsetX - rowMarkerWidth;
269
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
270
+ if (e.offsetY < headerHeight) {
271
+ if (!onHeaderContextMenu) return;
272
+ const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
273
+ if (col >= 0 && col < columns.length) {
274
+ e.preventDefault();
275
+ onHeaderContextMenu(col, e.clientX, e.clientY);
276
+ }
277
+ return;
278
+ }
279
+ const row = getRowFromOffsetY(e.offsetY);
280
+ if (row !== null) {
281
+ e.preventDefault();
282
+ onRowContextMenu?.(row, e.clientX, e.clientY);
283
+ }
284
+ if (e.offsetX < rowMarkerWidth) return;
285
+ if (onCellContextMenu) {
286
+ const cell = getCellFromPoint(localX, e.offsetY, effectiveScrollX, scrollY, effectiveColumns, rowHeight, headerHeight);
287
+ if (cell) {
288
+ e.preventDefault();
289
+ onCellContextMenu(cell, e.clientX, e.clientY);
290
+ }
291
+ }
292
+ }
293
+ // ── Sort helpers ──────────────────────────────────────────────────────────────
294
+ function toggleSort(col, multi = false) {
295
+ const column = columns[col];
296
+ if (!column || !onSortOrderChange || column.disableSort === true) return;
297
+ const current = sortOrder?.[column.id] ?? "none";
298
+ const next = current === "none" ? "asc" : current === "asc" ? "desc" : "none";
299
+ if (multi) {
300
+ onSortOrderChange({
301
+ ...sortOrder,
302
+ [column.id]: next
303
+ });
304
+ } else {
305
+ onSortOrderChange({ [column.id]: next });
306
+ }
307
+ }
308
+ // ── Mouse handlers ────────────────────────────────────────────────────────────
309
+ function getRowFromOffsetY(offsetY) {
310
+ if (offsetY < headerHeight) return null;
311
+ const row = Math.floor((offsetY - headerHeight + scrollY) / rowHeight);
312
+ return row >= 0 && row < rows ? row : null;
313
+ }
314
+ function handleMouseDown(e) {
315
+ if (e.button !== 0) return;
316
+ pendingGridMenuOpen = false;
317
+ containerRef?.focus();
318
+ const sbGeo = getScrollbarGeometry(buildRenderContext());
319
+ if (sbGeo.needsVScroll && e.offsetX >= sbGeo.vTrackX && e.offsetY >= sbGeo.vTrackY && e.offsetY < sbGeo.vTrackY + sbGeo.vTrackH) {
320
+ isDraggingVScrollbar = true;
321
+ vScrollDragStartY = e.clientY;
322
+ vScrollDragStartScroll = scrollY;
323
+ document.addEventListener("mousemove", handleDocumentMouseMove);
324
+ document.addEventListener("mouseup", handleDocumentMouseUp);
325
+ return;
326
+ }
327
+ if (sbGeo.needsHScroll && e.offsetY >= sbGeo.hTrackY && e.offsetX >= sbGeo.hTrackX && e.offsetX < sbGeo.hTrackX + sbGeo.hTrackW) {
328
+ isDraggingHScrollbar = true;
329
+ hScrollDragStartX = e.clientX;
330
+ hScrollDragStartScroll = scrollX;
331
+ document.addEventListener("mousemove", handleDocumentMouseMove);
332
+ document.addEventListener("mouseup", handleDocumentMouseUp);
333
+ return;
334
+ }
335
+ // Menu button click — handled before readonly check so it always fires.
336
+ if (onGridMenuOpen && rowMarkerWidth > 0 && e.offsetX < rowMarkerWidth && e.offsetY < headerHeight) {
337
+ pendingGridMenuOpen = true;
338
+ return;
339
+ }
340
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
341
+ const localX = e.offsetX - rowMarkerWidth;
342
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
343
+ // Header interactions — sort always active; resize/reorder/selection gated by readonly
344
+ if (e.offsetY < headerHeight) {
345
+ if (!readonly && resizableColumns) {
346
+ const resizeCol = getResizeHandle(localX, effectiveColumns, effectiveScrollX);
347
+ if (resizeCol !== null) {
348
+ resizingColumn = resizeCol;
349
+ return;
350
+ }
351
+ }
352
+ // Column drag reorder (only if onColumnMoved provided and not readonly)
353
+ if (!readonly && onColumnMoved) {
354
+ const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
355
+ draggingHeaderCol = col;
356
+ dragHeaderCurrentX = e.offsetX;
357
+ return;
358
+ }
359
+ // Column header click → sort toggle (always) + column selection (when not readonly)
360
+ const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
361
+ if (col >= 0 && col < columns.length) {
362
+ toggleSort(col, e.ctrlKey || e.metaKey || e.shiftKey);
363
+ if (!readonly) {
364
+ if (e.shiftKey && currentSelection.type === "column") {
365
+ const cols = [...currentSelection.columns];
366
+ if (!cols.includes(col)) cols.push(col);
367
+ currentSelection = {
368
+ type: "column",
369
+ columns: cols
370
+ };
371
+ } else {
372
+ currentSelection = {
373
+ type: "column",
374
+ columns: [col]
375
+ };
376
+ }
377
+ onSelectionChange?.(currentSelection);
378
+ announcement = `Column ${columns[col].title} selected`;
379
+ }
380
+ }
381
+ return;
382
+ }
383
+ const row = getRowFromOffsetY(e.offsetY);
384
+ if (row !== null) {
385
+ onRowClick?.(row);
386
+ }
387
+ if (readonly) return;
388
+ const cell = getCellFromPoint(localX, e.offsetY, effectiveScrollX, scrollY, effectiveColumns, rowHeight, headerHeight);
389
+ if (rowMarkers !== "none" && e.offsetX < rowMarkerWidth) {
390
+ if (e.offsetY >= headerHeight) {
391
+ const row = Math.floor((e.offsetY - headerHeight + scrollY) / rowHeight);
392
+ if (row >= 0 && row < rows) {
393
+ onRowToggle?.(row);
394
+ }
395
+ }
396
+ return;
397
+ }
398
+ if (cell) {
399
+ onCellClick?.(cell);
400
+ if (e.shiftKey && focusedCell) {
401
+ const newSelection = {
402
+ type: "range",
403
+ start: focusedCell,
404
+ end: cell
405
+ };
406
+ currentSelection = newSelection;
407
+ onSelectionChange?.(newSelection);
408
+ } else {
409
+ focusedCell = cell;
410
+ dragStart = cell;
411
+ isDragging = true;
412
+ currentSelection = {
413
+ type: "cell",
414
+ item: cell
415
+ };
416
+ onSelectionChange?.(currentSelection);
417
+ announcement = `Row ${cell[1] + 1}, column ${columns[cell[0]]?.title ?? cell[0] + 1}`;
418
+ }
419
+ }
420
+ }
421
+ function handleDblClick(e) {
422
+ if (e.offsetY < headerHeight) return;
423
+ if (rowMarkers !== "none" && e.offsetX < rowMarkerWidth) return;
424
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
425
+ const localX = e.offsetX - rowMarkerWidth;
426
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
427
+ const cell = getCellFromPoint(localX, e.offsetY, effectiveScrollX, scrollY, effectiveColumns, rowHeight, headerHeight);
428
+ if (cell) {
429
+ onCellDblClick?.(cell, getCellContent(cell));
430
+ if (!readonly) {
431
+ onBeginEdit(cell);
432
+ }
433
+ }
434
+ }
435
+ function handleDocumentMouseMove(e) {
436
+ if (isDraggingVScrollbar) {
437
+ const geo = getScrollbarGeometry(buildRenderContext());
438
+ const trackRange = geo.vTrackH - geo.vThumbH;
439
+ if (trackRange > 0 && scrollRef) {
440
+ const newScroll = vScrollDragStartScroll + (e.clientY - vScrollDragStartY) / trackRange * geo.maxScrollY;
441
+ scrollRef.scrollTop = Math.max(0, Math.min(geo.maxScrollY, newScroll));
442
+ }
443
+ } else if (isDraggingHScrollbar) {
444
+ const geo = getScrollbarGeometry(buildRenderContext());
445
+ const trackRange = geo.hTrackW - geo.hThumbW;
446
+ if (trackRange > 0 && scrollRef) {
447
+ const newScroll = hScrollDragStartScroll + (e.clientX - hScrollDragStartX) / trackRange * geo.maxScrollX;
448
+ scrollRef.scrollLeft = Math.max(0, Math.min(geo.maxScrollX, newScroll));
449
+ }
450
+ }
451
+ }
452
+ function handleDocumentMouseUp() {
453
+ isDraggingVScrollbar = false;
454
+ isDraggingHScrollbar = false;
455
+ scheduleDraw();
456
+ document.removeEventListener("mousemove", handleDocumentMouseMove);
457
+ document.removeEventListener("mouseup", handleDocumentMouseUp);
458
+ }
459
+ function handleMouseMove(e) {
460
+ if (isDraggingVScrollbar || isDraggingHScrollbar) return;
461
+ // Menu button hover.
462
+ if (onGridMenuOpen && rowMarkerWidth > 0) {
463
+ const isOver = e.offsetX < rowMarkerWidth && e.offsetY < headerHeight;
464
+ if (isOver !== hoverMenuButton) {
465
+ hoverMenuButton = isOver;
466
+ scheduleDraw();
467
+ }
468
+ }
469
+ const sbGeo = getScrollbarGeometry(buildRenderContext());
470
+ const newHoverV = sbGeo.needsVScroll && e.offsetX >= sbGeo.vTrackX && e.offsetY >= sbGeo.vTrackY && e.offsetY < sbGeo.vTrackY + sbGeo.vTrackH;
471
+ const newHoverH = sbGeo.needsHScroll && e.offsetY >= sbGeo.hTrackY && e.offsetX >= sbGeo.hTrackX && e.offsetX < sbGeo.hTrackX + sbGeo.hTrackW;
472
+ if (newHoverV !== isHoveringVScrollbar || newHoverH !== isHoveringHScrollbar) {
473
+ isHoveringVScrollbar = newHoverV;
474
+ isHoveringHScrollbar = newHoverH;
475
+ scheduleDraw();
476
+ }
477
+ // Sortable header hover (for pointer cursor)
478
+ if (onSortOrderChange && e.offsetY < headerHeight && e.offsetX >= rowMarkerWidth) {
479
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
480
+ const localX = e.offsetX - rowMarkerWidth;
481
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
482
+ const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
483
+ const isSortable = col >= 0 && col < columns.length && columns[col].disableSort !== true;
484
+ const newHoverSort = isSortable ? col : null;
485
+ if (newHoverSort !== hoverSortCol) {
486
+ hoverSortCol = newHoverSort;
487
+ }
488
+ } else if (hoverSortCol !== null) {
489
+ hoverSortCol = null;
490
+ }
491
+ if (resizableColumns && resizingColumn === null && e.offsetY < headerHeight) {
492
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
493
+ const localX = e.offsetX - rowMarkerWidth;
494
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
495
+ const resizeCol = getResizeHandle(localX, effectiveColumns, effectiveScrollX);
496
+ if (resizeCol !== hoverResizeCol) {
497
+ hoverResizeCol = resizeCol;
498
+ scheduleDraw();
499
+ }
500
+ } else if (hoverResizeCol !== null) {
501
+ hoverResizeCol = null;
502
+ scheduleDraw();
503
+ }
504
+ if (resizingColumn !== null) {
505
+ const baseX = getColumnX(effectiveColumns, resizingColumn) + rowMarkerWidth;
506
+ const scrollXForResize = fixedColumns > 0 && resizingColumn < fixedColumns ? 0 : scrollX;
507
+ const newWidth = Math.max(50, e.offsetX - baseX + scrollXForResize);
508
+ resizeWidth = newWidth;
509
+ scheduleDraw();
510
+ return;
511
+ }
512
+ if (draggingHeaderCol !== null) {
513
+ dragHeaderCurrentX = e.offsetX;
514
+ scheduleDraw();
515
+ return;
516
+ }
517
+ // Hover cell tracking
518
+ if (e.offsetY >= headerHeight && e.offsetX >= rowMarkerWidth) {
519
+ const hoverFixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
520
+ const hoverLocalX = e.offsetX - rowMarkerWidth;
521
+ const hoverScrollX = fixedColumns > 0 && hoverLocalX >= 0 && hoverLocalX < hoverFixedBoundaryX ? 0 : scrollX;
522
+ const hoverCell = getCellFromPoint(hoverLocalX, e.offsetY, hoverScrollX, scrollY, effectiveColumns, rowHeight, headerHeight);
523
+ if (hoverCell?.[0] !== hoveredCell?.[0] || hoverCell?.[1] !== hoveredCell?.[1]) {
524
+ if (hoveredCell) onCellLeave?.(hoveredCell);
525
+ hoveredCell = hoverCell;
526
+ if (hoverCell) onCellHover?.(hoverCell);
527
+ }
528
+ } else if (hoveredCell !== null) {
529
+ onCellLeave?.(hoveredCell);
530
+ hoveredCell = null;
531
+ }
532
+ if (!isDragging || !dragStart) return;
533
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
534
+ const localX = e.offsetX - rowMarkerWidth;
535
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
536
+ const cell = getCellFromPoint(localX, e.offsetY, effectiveScrollX, scrollY, effectiveColumns, rowHeight, headerHeight);
537
+ if (cell && (cell[0] !== dragStart[0] || cell[1] !== dragStart[1])) {
538
+ currentSelection = {
539
+ type: "range",
540
+ start: dragStart,
541
+ end: cell
542
+ };
543
+ onSelectionChange?.(currentSelection);
544
+ }
545
+ }
546
+ function handleMouseUp(e) {
547
+ if (pendingGridMenuOpen && onGridMenuOpen && rowMarkerWidth > 0 && e.offsetX < rowMarkerWidth && e.offsetY < headerHeight) {
548
+ onGridMenuOpen({
549
+ x: e.clientX,
550
+ y: e.clientY
551
+ });
552
+ }
553
+ pendingGridMenuOpen = false;
554
+ if (draggingHeaderCol !== null && onColumnMoved) {
555
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
556
+ const localX = e.offsetX - rowMarkerWidth;
557
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
558
+ const targetCol = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
559
+ if (targetCol !== draggingHeaderCol) {
560
+ onColumnMoved(draggingHeaderCol, targetCol);
561
+ } else {
562
+ // No actual drag movement — treat as a header click and toggle sort
563
+ toggleSort(draggingHeaderCol, e.ctrlKey || e.metaKey || e.shiftKey);
564
+ }
565
+ draggingHeaderCol = null;
566
+ scheduleDraw();
567
+ }
568
+ if (resizingColumn !== null && resizeWidth !== null) {
569
+ onColumnResized?.(resizingColumn, resizeWidth);
570
+ }
571
+ isDragging = false;
572
+ dragStart = null;
573
+ resizingColumn = null;
574
+ resizeWidth = null;
575
+ }
576
+ function handleMouseLeave(e) {
577
+ isHoveringVScrollbar = false;
578
+ isHoveringHScrollbar = false;
579
+ hoverResizeCol = null;
580
+ hoverMenuButton = false;
581
+ hoverSortCol = null;
582
+ pendingGridMenuOpen = false;
583
+ if (hoveredCell) {
584
+ onCellLeave?.(hoveredCell);
585
+ hoveredCell = null;
586
+ }
587
+ if (!isDraggingVScrollbar && !isDraggingHScrollbar) {
588
+ handleMouseUp(e);
589
+ } else {
590
+ scheduleDraw();
591
+ }
592
+ }
593
+ function handleWheel(e) {
594
+ if (scrollRef) {
595
+ e.preventDefault();
596
+ scrollRef.scrollLeft += e.deltaX;
597
+ scrollRef.scrollTop += e.deltaY;
598
+ }
599
+ }
600
+ // ── Focus handlers ────────────────────────────────────────────────────────────
601
+ export function handleFocusIn() {
602
+ hasFocus = true;
603
+ if (!focusedCell && rows > 0 && columns.length > 0) {
604
+ if (currentSelection.type === "cell") {
605
+ focusedCell = currentSelection.item;
606
+ } else if (currentSelection.type === "range") {
607
+ focusedCell = currentSelection.start;
608
+ } else if (currentSelection.type === "row") {
609
+ focusedCell = [0, currentSelection.rows[0] ?? 0];
610
+ } else if (currentSelection.type === "column") {
611
+ focusedCell = [currentSelection.columns[0] ?? 0, 0];
612
+ } else {
613
+ focusedCell = [0, 0];
614
+ currentSelection = {
615
+ type: "cell",
616
+ item: focusedCell
617
+ };
618
+ onSelectionChange?.(currentSelection);
619
+ }
620
+ }
621
+ }
622
+ export function handleFocusOut(e) {
623
+ const next = e.relatedTarget;
624
+ if (next && containerRef?.contains(next)) return;
625
+ hasFocus = false;
626
+ }
627
+ export function getHasFocus() {
628
+ return hasFocus;
629
+ }
630
+ export function getAnnouncement() {
631
+ return announcement;
632
+ }
633
+ export function setAnnouncement(msg) {
634
+ announcement = msg;
635
+ }
636
+ function getLocalPointFromClient(clientX, clientY) {
637
+ const rect = canvasRef?.getBoundingClientRect();
638
+ if (!rect) return null;
639
+ return {
640
+ offsetX: clientX - rect.left,
641
+ offsetY: clientY - rect.top
642
+ };
643
+ }
644
+ function handleTouchTap(offsetX, offsetY) {
645
+ containerRef?.focus();
646
+ if (onGridMenuOpen && rowMarkerWidth > 0 && offsetX < rowMarkerWidth && offsetY < headerHeight) {
647
+ const rect = canvasRef?.getBoundingClientRect();
648
+ onGridMenuOpen({
649
+ x: (rect?.left ?? 0) + offsetX,
650
+ y: (rect?.top ?? 0) + offsetY
651
+ });
652
+ return;
653
+ }
654
+ const fixedBoundaryX = fixedColumns > 0 ? getColumnX(effectiveColumns, Math.min(fixedColumns, columns.length)) : 0;
655
+ const localX = offsetX - rowMarkerWidth;
656
+ const effectiveScrollX = fixedColumns > 0 && localX >= 0 && localX < fixedBoundaryX ? 0 : scrollX;
657
+ if (offsetY < headerHeight) {
658
+ const col = getColumnFromX(localX + effectiveScrollX, effectiveColumns);
659
+ if (col >= 0 && col < columns.length) {
660
+ toggleSort(col, false);
661
+ if (!readonly) {
662
+ currentSelection = {
663
+ type: "column",
664
+ columns: [col]
665
+ };
666
+ onSelectionChange?.(currentSelection);
667
+ announcement = `Column ${columns[col].title} selected`;
668
+ }
669
+ }
670
+ return;
671
+ }
672
+ if (rowMarkers !== "none" && offsetX < rowMarkerWidth) {
673
+ const row = getRowFromOffsetY(offsetY);
674
+ if (row !== null && row >= 0 && row < rows) {
675
+ onRowToggle?.(row);
676
+ }
677
+ return;
678
+ }
679
+ const cell = getCellFromPoint(localX, offsetY, effectiveScrollX, scrollY, effectiveColumns, rowHeight, headerHeight);
680
+ if (cell) {
681
+ focusedCell = cell;
682
+ currentSelection = {
683
+ type: "cell",
684
+ item: cell
685
+ };
686
+ onSelectionChange?.(currentSelection);
687
+ announcement = `Row ${cell[1] + 1}, column ${columns[cell[0]]?.title ?? cell[0] + 1}`;
688
+ }
689
+ }
690
+ function handleTouchStart(e) {
691
+ if (e.touches.length !== 1) return;
692
+ const touch = e.touches[0];
693
+ touchStartClientX = touch.clientX;
694
+ touchStartClientY = touch.clientY;
695
+ touchStartScrollX = scrollRef?.scrollLeft ?? 0;
696
+ touchStartScrollY = scrollRef?.scrollTop ?? 0;
697
+ isTouchScrolling = false;
698
+ }
699
+ function handleTouchMove(e) {
700
+ if (e.touches.length !== 1 || !scrollRef) return;
701
+ const touch = e.touches[0];
702
+ const deltaX = touch.clientX - touchStartClientX;
703
+ const deltaY = touch.clientY - touchStartClientY;
704
+ if (!isTouchScrolling && (Math.abs(deltaX) > 8 || Math.abs(deltaY) > 8)) {
705
+ isTouchScrolling = true;
706
+ }
707
+ if (!isTouchScrolling) return;
708
+ e.preventDefault();
709
+ scrollRef.scrollLeft = touchStartScrollX - deltaX;
710
+ scrollRef.scrollTop = touchStartScrollY - deltaY;
711
+ }
712
+ function handleTouchEnd(e) {
713
+ const touch = e.changedTouches[0];
714
+ if (!touch) return;
715
+ const point = getLocalPointFromClient(touch.clientX, touch.clientY);
716
+ const wasScrolling = isTouchScrolling;
717
+ isTouchScrolling = false;
718
+ if (!point || wasScrolling) return;
719
+ handleTouchTap(point.offsetX, point.offsetY);
720
+ }
1116
721
  </script>
1117
722
 
1118
723
  <div