dbdiff-app 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.
Files changed (69) hide show
  1. package/README.md +73 -0
  2. package/bin/cli.js +83 -0
  3. package/bin/install-local.js +57 -0
  4. package/electron/generate-icon.mjs +54 -0
  5. package/electron/icon.icns +0 -0
  6. package/electron/icon.png +0 -0
  7. package/electron/icon.svg +21 -0
  8. package/electron/main.js +169 -0
  9. package/electron/patch-dev-plist.js +31 -0
  10. package/electron/preload.cjs +18 -0
  11. package/electron/wait-for-vite.js +43 -0
  12. package/index.html +13 -0
  13. package/package.json +91 -0
  14. package/public/favicon.svg +15 -0
  15. package/public/vite.svg +1 -0
  16. package/server/export.ts +57 -0
  17. package/server/index.ts +392 -0
  18. package/src/App.css +1 -0
  19. package/src/App.tsx +543 -0
  20. package/src/assets/react.svg +1 -0
  21. package/src/components/CommandPalette.tsx +243 -0
  22. package/src/components/ConnectedView.tsx +78 -0
  23. package/src/components/ConnectionPicker.tsx +381 -0
  24. package/src/components/ConsoleView.tsx +360 -0
  25. package/src/components/CsvExportModal.tsx +144 -0
  26. package/src/components/DataGrid/DataGrid.tsx +262 -0
  27. package/src/components/DataGrid/DataGridCell.tsx +73 -0
  28. package/src/components/DataGrid/DataGridHeader.tsx +89 -0
  29. package/src/components/DataGrid/index.ts +20 -0
  30. package/src/components/DataGrid/types.ts +63 -0
  31. package/src/components/DataGrid/useColumnResize.ts +153 -0
  32. package/src/components/DataGrid/useDataGridSelection.ts +340 -0
  33. package/src/components/DataGrid/utils.ts +184 -0
  34. package/src/components/DatabaseMenu.tsx +93 -0
  35. package/src/components/DatabaseSwitcher.tsx +208 -0
  36. package/src/components/DiffView.tsx +215 -0
  37. package/src/components/EditConnectionModal.tsx +417 -0
  38. package/src/components/ErrorBoundary.tsx +69 -0
  39. package/src/components/GlobalShortcuts.tsx +201 -0
  40. package/src/components/InnerTabBar.tsx +129 -0
  41. package/src/components/JsonTreeViewer.tsx +387 -0
  42. package/src/components/MemberAccessEditor.tsx +443 -0
  43. package/src/components/MembersModal.tsx +446 -0
  44. package/src/components/NewConnectionModal.tsx +274 -0
  45. package/src/components/Resizer.tsx +66 -0
  46. package/src/components/ScanSuccessModal.tsx +113 -0
  47. package/src/components/ShortcutSettingsModal.tsx +318 -0
  48. package/src/components/Sidebar.tsx +532 -0
  49. package/src/components/TabBar.tsx +188 -0
  50. package/src/components/TableView.tsx +2147 -0
  51. package/src/components/ThemeToggle.tsx +44 -0
  52. package/src/components/index.ts +17 -0
  53. package/src/constants.ts +12 -0
  54. package/src/electron.d.ts +12 -0
  55. package/src/index.css +44 -0
  56. package/src/main.tsx +13 -0
  57. package/src/stores/hooks.ts +1146 -0
  58. package/src/stores/index.ts +12 -0
  59. package/src/stores/store.ts +1514 -0
  60. package/src/stores/useCloudSync.ts +274 -0
  61. package/src/stores/useSyncDatabase.ts +422 -0
  62. package/src/types.ts +277 -0
  63. package/src/utils/csv.ts +27 -0
  64. package/src/vite-env.d.ts +2 -0
  65. package/tsconfig.app.json +28 -0
  66. package/tsconfig.json +7 -0
  67. package/tsconfig.node.json +26 -0
  68. package/tsconfig.server.json +14 -0
  69. package/vite.config.ts +14 -0
@@ -0,0 +1,262 @@
1
+ import { useVirtualizer } from "@tanstack/react-virtual";
2
+ import { useCallback, useRef } from "react";
3
+ import type { DataGridProps } from "./types";
4
+ import { getCellRangeInfo, ROW_HEIGHT, virtualToStoreIndex } from "./utils";
5
+ import { DataGridHeader } from "./DataGridHeader";
6
+ import { DataGridCell } from "./DataGridCell";
7
+ import { useColumnResize } from "./useColumnResize";
8
+ import { useDataGridSelection } from "./useDataGridSelection";
9
+
10
+ export function DataGrid({
11
+ columns,
12
+ rows,
13
+ extraRows,
14
+ sortColumns,
15
+ onSortChange,
16
+ selection: controlledSelection,
17
+ onSelectionChange,
18
+ renderCell,
19
+ onKeyDown,
20
+ className,
21
+ scrollRef: externalScrollRef,
22
+ tableRef: externalTableRef,
23
+ onHeaderContextMenu,
24
+ fkPreviewActiveColumns,
25
+ }: DataGridProps) {
26
+ const internalScrollRef = useRef<HTMLDivElement>(null);
27
+ const internalTableRef = useRef<HTMLTableElement>(null);
28
+ const scrollRef = externalScrollRef ?? internalScrollRef;
29
+ const tableRef = externalTableRef ?? internalTableRef;
30
+
31
+ const totalVirtualRowCount = rows.length + (extraRows?.length ?? 0);
32
+
33
+ const virtualizer = useVirtualizer({
34
+ count: totalVirtualRowCount,
35
+ getScrollElement: () => scrollRef.current,
36
+ estimateSize: () => ROW_HEIGHT,
37
+ overscan: 15,
38
+ });
39
+
40
+ const { columnWidths, resizingColumn, justResizedRef, handleResizeStart } =
41
+ useColumnResize({
42
+ columns,
43
+ scrollRef,
44
+ tableRef,
45
+ });
46
+
47
+ const scrollToIndex = useCallback(
48
+ (index: number) => {
49
+ virtualizer.scrollToIndex(index);
50
+ },
51
+ [virtualizer],
52
+ );
53
+
54
+ const {
55
+ selection,
56
+ handleCellClick,
57
+ handleCellMouseDown,
58
+ handleCellMouseEnter,
59
+ } = useDataGridSelection({
60
+ columns,
61
+ rows,
62
+ extraRows,
63
+ selection: controlledSelection,
64
+ onSelectionChange,
65
+ onKeyDown,
66
+ scrollToIndex,
67
+ });
68
+
69
+ const handleColumnClick = useCallback(
70
+ (columnName: string, e: React.MouseEvent) => {
71
+ e.preventDefault();
72
+ if (justResizedRef.current) return;
73
+ const addToExisting = e.ctrlKey || e.metaKey;
74
+ onSortChange?.(columnName, addToExisting);
75
+ },
76
+ [onSortChange, justResizedRef],
77
+ );
78
+
79
+ const columnNames = columns.map((c) => c.name);
80
+
81
+ return (
82
+ <div
83
+ ref={scrollRef}
84
+ className={`flex-1 overflow-auto pb-8 pr-4 ${className ?? ""}`}
85
+ >
86
+ <table
87
+ ref={tableRef}
88
+ className={`w-full text-[13px] border-collapse ${
89
+ selection.isDragging || resizingColumn ? "select-none" : ""
90
+ }`}
91
+ style={{ tableLayout: "fixed" }}
92
+ >
93
+ <colgroup>
94
+ {columns.map((col) => (
95
+ <col
96
+ key={col.name}
97
+ style={{ width: columnWidths[col.name] ?? 150 }}
98
+ />
99
+ ))}
100
+ </colgroup>
101
+ <thead className="sticky top-0 z-10 bg-stone-100 dark:bg-neutral-900">
102
+ <tr>
103
+ {columns.map((col, i) => (
104
+ <DataGridHeader
105
+ key={i}
106
+ columnName={col.name}
107
+ sortColumns={sortColumns}
108
+ onClick={onSortChange ? handleColumnClick : undefined}
109
+ onResizeStart={handleResizeStart}
110
+ onContextMenu={onHeaderContextMenu}
111
+ fkPreviewActive={fkPreviewActiveColumns?.has(col.name)}
112
+ />
113
+ ))}
114
+ </tr>
115
+ </thead>
116
+ <tbody>
117
+ {(() => {
118
+ const virtualItems = virtualizer.getVirtualItems();
119
+ const paddingTop = virtualItems[0]?.start ?? 0;
120
+ const paddingBottom =
121
+ virtualItems.length > 0
122
+ ? virtualizer.getTotalSize() -
123
+ virtualItems[virtualItems.length - 1].end
124
+ : 0;
125
+ return (
126
+ <>
127
+ {paddingTop > 0 && (
128
+ <tr>
129
+ <td
130
+ style={{ height: paddingTop, padding: 0 }}
131
+ colSpan={columns.length}
132
+ />
133
+ </tr>
134
+ )}
135
+ {virtualItems.map((virtualItem) => {
136
+ const isExtraRow = virtualItem.index >= rows.length;
137
+ const storeIndex = virtualToStoreIndex(
138
+ virtualItem.index,
139
+ rows.length,
140
+ );
141
+
142
+ if (isExtraRow) {
143
+ const extraRowIdx = virtualItem.index - rows.length;
144
+ const extraRow = extraRows?.[extraRowIdx];
145
+ if (!extraRow) return null;
146
+
147
+ return (
148
+ <tr key={`extra-${extraRow.key}`}>
149
+ {columns.map((col, colIndex) => {
150
+ const value = extraRow.data[col.name] ?? null;
151
+ const rangeInfo = getCellRangeInfo(
152
+ { rowIndex: storeIndex, columnName: col.name },
153
+ selection.selectedRange,
154
+ columnNames,
155
+ rows.length,
156
+ );
157
+ const isSelected =
158
+ selection.selectedCell?.rowIndex === storeIndex &&
159
+ selection.selectedCell?.columnName === col.name;
160
+
161
+ if (renderCell) {
162
+ return renderCell({
163
+ rowIndex: storeIndex,
164
+ columnIndex: colIndex,
165
+ columnName: col.name,
166
+ value,
167
+ isSelected,
168
+ isInRange: rangeInfo.isInRange,
169
+ rangeEdges: rangeInfo.edges,
170
+ onClick: handleCellClick,
171
+ onMouseDown: handleCellMouseDown,
172
+ onMouseEnter: handleCellMouseEnter,
173
+ });
174
+ }
175
+
176
+ return (
177
+ <DataGridCell
178
+ key={colIndex}
179
+ rowIndex={storeIndex}
180
+ columnName={col.name}
181
+ value={value}
182
+ isSelected={isSelected}
183
+ isInRange={rangeInfo.isInRange}
184
+ rangeEdges={rangeInfo.edges}
185
+ onClick={handleCellClick}
186
+ onMouseDown={handleCellMouseDown}
187
+ onMouseEnter={handleCellMouseEnter}
188
+ />
189
+ );
190
+ })}
191
+ </tr>
192
+ );
193
+ }
194
+
195
+ const row = rows[storeIndex];
196
+ if (!row) return null;
197
+
198
+ return (
199
+ <tr
200
+ key={storeIndex}
201
+ className="hover:bg-stone-50 dark:hover:bg-white/[0.02]"
202
+ >
203
+ {columns.map((col, colIndex) => {
204
+ const rangeInfo = getCellRangeInfo(
205
+ { rowIndex: storeIndex, columnName: col.name },
206
+ selection.selectedRange,
207
+ columnNames,
208
+ rows.length,
209
+ );
210
+ const isSelected =
211
+ selection.selectedCell?.rowIndex === storeIndex &&
212
+ selection.selectedCell?.columnName === col.name;
213
+
214
+ if (renderCell) {
215
+ return renderCell({
216
+ rowIndex: storeIndex,
217
+ columnIndex: colIndex,
218
+ columnName: col.name,
219
+ value: row[col.name],
220
+ isSelected,
221
+ isInRange: rangeInfo.isInRange,
222
+ rangeEdges: rangeInfo.edges,
223
+ onClick: handleCellClick,
224
+ onMouseDown: handleCellMouseDown,
225
+ onMouseEnter: handleCellMouseEnter,
226
+ });
227
+ }
228
+
229
+ return (
230
+ <DataGridCell
231
+ key={colIndex}
232
+ rowIndex={storeIndex}
233
+ columnName={col.name}
234
+ value={row[col.name]}
235
+ isSelected={isSelected}
236
+ isInRange={rangeInfo.isInRange}
237
+ rangeEdges={rangeInfo.edges}
238
+ onClick={handleCellClick}
239
+ onMouseDown={handleCellMouseDown}
240
+ onMouseEnter={handleCellMouseEnter}
241
+ />
242
+ );
243
+ })}
244
+ </tr>
245
+ );
246
+ })}
247
+ {paddingBottom > 0 && (
248
+ <tr>
249
+ <td
250
+ style={{ height: paddingBottom, padding: 0 }}
251
+ colSpan={columns.length}
252
+ />
253
+ </tr>
254
+ )}
255
+ </>
256
+ );
257
+ })()}
258
+ </tbody>
259
+ </table>
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+ import type { RangeEdges } from "./types";
3
+ import { formatCellValue } from "./utils";
4
+
5
+ interface DataGridCellInternalProps {
6
+ rowIndex: number;
7
+ columnName: string;
8
+ value: unknown;
9
+ isSelected: boolean;
10
+ isInRange: boolean;
11
+ rangeEdges: RangeEdges | null;
12
+ onClick: (rowIndex: number, columnName: string, e: React.MouseEvent) => void;
13
+ onMouseDown: (
14
+ rowIndex: number,
15
+ columnName: string,
16
+ e: React.MouseEvent,
17
+ ) => void;
18
+ onMouseEnter: (rowIndex: number, columnName: string) => void;
19
+ }
20
+
21
+ export const DataGridCell = React.memo(function DataGridCell({
22
+ rowIndex,
23
+ columnName,
24
+ value,
25
+ isSelected,
26
+ isInRange,
27
+ rangeEdges,
28
+ onClick,
29
+ onMouseDown,
30
+ onMouseEnter,
31
+ }: DataGridCellInternalProps) {
32
+ let cellClassName =
33
+ "px-3 py-2 text-secondary border-b border-r border-stone-200 dark:border-white/[0.06] font-mono max-w-[300px] cursor-pointer";
34
+
35
+ if (isSelected) {
36
+ cellClassName += " bg-blue-100 dark:bg-blue-800/40";
37
+ }
38
+
39
+ if (isInRange && rangeEdges && !isSelected) {
40
+ cellClassName += " bg-blue-50 dark:bg-blue-900/20";
41
+ }
42
+
43
+ const rangeBorderStyle: React.CSSProperties = {};
44
+ if (isInRange && rangeEdges) {
45
+ const borderColor = "rgb(59, 130, 246)";
46
+ const shadows: string[] = [];
47
+ if (rangeEdges.top) shadows.push(`inset 0 2px 0 0 ${borderColor}`);
48
+ if (rangeEdges.bottom) shadows.push(`inset 0 -2px 0 0 ${borderColor}`);
49
+ if (rangeEdges.left) shadows.push(`inset 2px 0 0 0 ${borderColor}`);
50
+ if (rangeEdges.right) shadows.push(`inset -2px 0 0 0 ${borderColor}`);
51
+ if (shadows.length > 0) {
52
+ rangeBorderStyle.boxShadow = shadows.join(", ");
53
+ }
54
+ }
55
+
56
+ return (
57
+ <td
58
+ className={cellClassName}
59
+ style={rangeBorderStyle}
60
+ onClick={(e) => onClick(rowIndex, columnName, e)}
61
+ onMouseDown={(e) => onMouseDown(rowIndex, columnName, e)}
62
+ onMouseEnter={() => onMouseEnter(rowIndex, columnName)}
63
+ >
64
+ <div className="truncate">
65
+ {value === null ? (
66
+ <span className="text-tertiary italic">NULL</span>
67
+ ) : (
68
+ formatCellValue(value)
69
+ )}
70
+ </div>
71
+ </td>
72
+ );
73
+ });
@@ -0,0 +1,89 @@
1
+ import React from "react";
2
+ import type { SortColumn } from "../../types";
3
+
4
+ interface DataGridHeaderProps {
5
+ columnName: string;
6
+ sortColumns?: SortColumn[];
7
+ onClick?: (columnName: string, e: React.MouseEvent) => void;
8
+ onResizeStart: (columnName: string, clientX: number) => void;
9
+ onContextMenu?: (columnName: string, e: React.MouseEvent) => void;
10
+ fkPreviewActive?: boolean;
11
+ }
12
+
13
+ export const DataGridHeader = React.memo(function DataGridHeader({
14
+ columnName,
15
+ sortColumns,
16
+ onClick,
17
+ onResizeStart,
18
+ onContextMenu,
19
+ fkPreviewActive,
20
+ }: DataGridHeaderProps) {
21
+ const sortIndex = sortColumns?.findIndex((s) => s.column === columnName);
22
+ const sortInfo =
23
+ sortIndex != null && sortIndex !== -1 ? sortColumns![sortIndex] : null;
24
+ const showPriority =
25
+ sortColumns != null && sortColumns.length > 1 && sortInfo;
26
+
27
+ return (
28
+ <th
29
+ onClick={onClick ? (e) => onClick(columnName, e) : undefined}
30
+ onContextMenu={(e) => {
31
+ e.preventDefault();
32
+ onContextMenu?.(columnName, e);
33
+ }}
34
+ className={`text-left px-3 py-2 font-medium text-primary border-b border-r border-stone-200 dark:border-white/[0.06] ${onClick ? "cursor-pointer" : ""} hover:bg-stone-200 bg-stone-100 dark:bg-neutral-900 dark:hover:bg-neutral-800 select-none transition-colors relative`}
35
+ >
36
+ <div className="flex items-center gap-1.5 overflow-hidden">
37
+ <span className="truncate">{columnName}</span>
38
+ {fkPreviewActive && (
39
+ <span
40
+ className="w-1.5 h-1.5 rounded-full bg-violet-500 dark:bg-violet-400 flex-shrink-0"
41
+ title="FK preview active"
42
+ />
43
+ )}
44
+ {sortInfo && (
45
+ <span className="flex items-center gap-0.5 text-blue-600 dark:text-blue-400 flex-shrink-0">
46
+ {sortInfo.direction === "ASC" ? (
47
+ <svg
48
+ className="w-3.5 h-3.5"
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ strokeWidth="2"
53
+ >
54
+ <polyline points="18 15 12 9 6 15" />
55
+ </svg>
56
+ ) : (
57
+ <svg
58
+ className="w-3.5 h-3.5"
59
+ viewBox="0 0 24 24"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ strokeWidth="2"
63
+ >
64
+ <polyline points="6 9 12 15 18 9" />
65
+ </svg>
66
+ )}
67
+ {showPriority && (
68
+ <span className="text-[10px] font-bold">{sortIndex! + 1}</span>
69
+ )}
70
+ </span>
71
+ )}
72
+ </div>
73
+ {/* Resize handle */}
74
+ <div
75
+ onMouseDown={(e) => {
76
+ e.stopPropagation();
77
+ onResizeStart(columnName, e.clientX);
78
+ }}
79
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize bg-transparent hover:bg-blue-500/50 active:bg-blue-500/50 transition-colors"
80
+ style={{
81
+ marginLeft: -4,
82
+ marginRight: -4,
83
+ paddingLeft: 4,
84
+ paddingRight: 4,
85
+ }}
86
+ />
87
+ </th>
88
+ );
89
+ });
@@ -0,0 +1,20 @@
1
+ export { DataGrid } from "./DataGrid";
2
+ export type {
3
+ DataGridColumn,
4
+ DataGridSelection,
5
+ DataGridCellProps,
6
+ DataGridProps,
7
+ ExtraRow,
8
+ RangeEdges,
9
+ } from "./types";
10
+ export {
11
+ ROW_HEIGHT,
12
+ storeToVirtualIndex,
13
+ virtualToStoreIndex,
14
+ getSelectedRowIndices,
15
+ getCellRangeInfo,
16
+ formatCellValue,
17
+ getInternalClipboardValue,
18
+ parseTSV,
19
+ isInternalRangeCopy,
20
+ } from "./utils";
@@ -0,0 +1,63 @@
1
+ import type { CellPosition, CellRange, SortColumn } from "../../types";
2
+
3
+ export interface RangeEdges {
4
+ top: boolean;
5
+ bottom: boolean;
6
+ left: boolean;
7
+ right: boolean;
8
+ }
9
+
10
+ export interface DataGridColumn {
11
+ name: string;
12
+ dataTypeID?: number;
13
+ }
14
+
15
+ export interface DataGridSelection {
16
+ selectedCell: CellPosition | null;
17
+ selectedRange: CellRange | null;
18
+ isDragging: boolean;
19
+ }
20
+
21
+ export interface DataGridCellProps {
22
+ rowIndex: number;
23
+ columnIndex: number;
24
+ columnName: string;
25
+ value: unknown;
26
+ isSelected: boolean;
27
+ isInRange: boolean;
28
+ rangeEdges: RangeEdges | null;
29
+ onClick: (rowIndex: number, columnName: string, e: React.MouseEvent) => void;
30
+ onMouseDown: (
31
+ rowIndex: number,
32
+ columnName: string,
33
+ e: React.MouseEvent,
34
+ ) => void;
35
+ onMouseEnter: (rowIndex: number, columnName: string) => void;
36
+ }
37
+
38
+ export interface ExtraRow {
39
+ key: string;
40
+ data: Record<string, unknown>;
41
+ }
42
+
43
+ export interface DataGridProps {
44
+ columns: DataGridColumn[];
45
+ rows: Record<string, unknown>[];
46
+ extraRows?: ExtraRow[];
47
+ sortColumns?: SortColumn[];
48
+ onSortChange?: (column: string, addToExisting: boolean) => void;
49
+ selection?: DataGridSelection;
50
+ onSelectionChange?: (selection: DataGridSelection) => void;
51
+ renderCell?: (props: DataGridCellProps) => React.ReactNode;
52
+ /** Return true if the key was handled (prevents DataGrid's default handling) */
53
+ onKeyDown?: (e: KeyboardEvent) => boolean;
54
+ className?: string;
55
+ /** Ref to the scroll container element */
56
+ scrollRef?: React.RefObject<HTMLDivElement | null>;
57
+ /** Ref to the table element */
58
+ tableRef?: React.RefObject<HTMLTableElement | null>;
59
+ /** Called when user right-clicks a column header */
60
+ onHeaderContextMenu?: (columnName: string, e: React.MouseEvent) => void;
61
+ /** Set of column names that have active FK preview */
62
+ fkPreviewActiveColumns?: Set<string>;
63
+ }
@@ -0,0 +1,153 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { DataGridColumn } from "./types";
3
+
4
+ interface UseColumnResizeOptions {
5
+ columns: DataGridColumn[];
6
+ scrollRef: React.RefObject<HTMLDivElement | null>;
7
+ tableRef: React.RefObject<HTMLTableElement | null>;
8
+ }
9
+
10
+ export function useColumnResize({
11
+ columns,
12
+ scrollRef,
13
+ tableRef,
14
+ }: UseColumnResizeOptions) {
15
+ const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
16
+ const [resizingColumn, setResizingColumn] = useState<{
17
+ name: string;
18
+ startX: number;
19
+ startWidth: number;
20
+ startScrollLeft: number;
21
+ } | null>(null);
22
+ const justResizedRef = useRef(false);
23
+ const lastClientXRef = useRef(0);
24
+
25
+ // Reset column widths when columns change
26
+ const fieldKey = useMemo(
27
+ () => columns.map((f) => f.name).join(","),
28
+ [columns],
29
+ );
30
+ useEffect(() => {
31
+ if (columns.length > 0) {
32
+ const widths: Record<string, number> = {};
33
+ for (const col of columns) {
34
+ widths[col.name] = 150;
35
+ }
36
+ setColumnWidths(widths);
37
+ }
38
+ }, [fieldKey]); // eslint-disable-line react-hooks/exhaustive-deps
39
+
40
+ const handleResizeStart = useCallback(
41
+ (columnName: string, clientX: number) => {
42
+ lastClientXRef.current = clientX;
43
+
44
+ // Read actual rendered widths from the DOM so that all columns have
45
+ // accurate state before the drag begins. Without this, columns whose
46
+ // state width (150px) differs from their visual width (wider due to
47
+ // table-layout:fixed + w-full proportional distribution) would appear
48
+ // to compress when a sibling column is resized.
49
+ let startWidth = columnWidths[columnName] ?? 150;
50
+ if (tableRef.current) {
51
+ const ths = tableRef.current.querySelectorAll("thead > tr > th");
52
+ const actualWidths: Record<string, number> = {};
53
+ columns.forEach((col, i) => {
54
+ if (ths[i]) {
55
+ actualWidths[col.name] = Math.round(
56
+ ths[i].getBoundingClientRect().width,
57
+ );
58
+ } else {
59
+ actualWidths[col.name] = columnWidths[col.name] ?? 150;
60
+ }
61
+ });
62
+ startWidth = actualWidths[columnName] ?? startWidth;
63
+ setColumnWidths(actualWidths);
64
+ }
65
+
66
+ setResizingColumn({
67
+ name: columnName,
68
+ startX: clientX,
69
+ startWidth,
70
+ startScrollLeft: scrollRef.current?.scrollLeft ?? 0,
71
+ });
72
+ },
73
+ [columnWidths, scrollRef, columns, tableRef],
74
+ );
75
+
76
+ useEffect(() => {
77
+ if (!resizingColumn) return;
78
+
79
+ const colIndex = columns.findIndex((f) => f.name === resizingColumn.name);
80
+ const colEl =
81
+ colIndex >= 0
82
+ ? tableRef.current?.querySelector<HTMLElement>(
83
+ `colgroup > col:nth-child(${colIndex + 1})`,
84
+ )
85
+ : null;
86
+
87
+ const scrollEl = scrollRef.current;
88
+ const EDGE_ZONE = 40;
89
+ const SCROLL_SPEED = 12;
90
+ let rafId = 0;
91
+ let alive = true;
92
+
93
+ const computeWidth = () =>
94
+ Math.max(
95
+ 50,
96
+ resizingColumn.startWidth +
97
+ (lastClientXRef.current - resizingColumn.startX) +
98
+ ((scrollEl?.scrollLeft ?? 0) - resizingColumn.startScrollLeft),
99
+ );
100
+
101
+ const tick = () => {
102
+ if (!alive) return;
103
+ if (scrollEl) {
104
+ const rect = scrollEl.getBoundingClientRect();
105
+ const x = lastClientXRef.current;
106
+ if (x > rect.right - EDGE_ZONE) {
107
+ scrollEl.scrollLeft += SCROLL_SPEED;
108
+ } else if (x < rect.left + EDGE_ZONE) {
109
+ scrollEl.scrollLeft -= SCROLL_SPEED;
110
+ }
111
+ }
112
+ if (colEl) {
113
+ colEl.style.width = `${computeWidth()}px`;
114
+ }
115
+ rafId = requestAnimationFrame(tick);
116
+ };
117
+ rafId = requestAnimationFrame(tick);
118
+
119
+ const handleMouseMove = (e: MouseEvent) => {
120
+ lastClientXRef.current = e.clientX;
121
+ };
122
+
123
+ const handleMouseUp = () => {
124
+ alive = false;
125
+ cancelAnimationFrame(rafId);
126
+ setColumnWidths((prev) => ({
127
+ ...prev,
128
+ [resizingColumn.name]: computeWidth(),
129
+ }));
130
+ justResizedRef.current = true;
131
+ requestAnimationFrame(() => {
132
+ justResizedRef.current = false;
133
+ });
134
+ setResizingColumn(null);
135
+ };
136
+
137
+ window.addEventListener("mousemove", handleMouseMove);
138
+ window.addEventListener("mouseup", handleMouseUp);
139
+ return () => {
140
+ alive = false;
141
+ cancelAnimationFrame(rafId);
142
+ window.removeEventListener("mousemove", handleMouseMove);
143
+ window.removeEventListener("mouseup", handleMouseUp);
144
+ };
145
+ }, [resizingColumn, columns, scrollRef, tableRef]);
146
+
147
+ return {
148
+ columnWidths,
149
+ resizingColumn,
150
+ justResizedRef,
151
+ handleResizeStart,
152
+ };
153
+ }