Line data Source code
1 : import * as arrow from 'apache-arrow';
2 : import * as React from 'react';
3 : import * as styles from './data_table.module.css';
4 :
5 : import { DataTableLayout } from './data_table_layout.js';
6 :
7 : /// The distance in pixels from a column border that still grabs the border
8 1 : const RESIZE_HANDLE_TOLERANCE = 4;
9 : /// Resized columns stay wide enough to show a few characters
10 : export const MIN_RESIZED_COLUMN_WIDTH = 40;
11 :
12 : /// Widths of the columns that the user resized, keyed by field name
13 : export type ResizedColumnWidths = ReadonlyMap<string, number>;
14 :
15 : /// Find the column whose right border is under the pointer.
16 : /// The sticky row-number column is not resizable and borders scrolled behind it can't be grabbed.
17 1 : export function findResizableColumn(layout: DataTableLayout, contentX: number, viewportX: number): number | null {
18 1 : const offsets = layout.columnXOffsets;
19 1 : if (layout.columnCount < 2) {
20 0 : return null;
21 : }
22 : // Find the first data column whose right border is not left of the pointer
23 1 : let lo = 1;
24 1 : let hi = layout.columnCount - 1;
25 1 : while (lo < hi) {
26 1 : const mid = (lo + hi) >> 1;
27 1 : if (offsets[mid + 1] < contentX - RESIZE_HANDLE_TOLERANCE) {
28 1 : lo = mid + 1;
29 : } else {
30 1 : hi = mid;
31 : }
32 1 : }
33 1 : const border = offsets[lo + 1];
34 1 : if (Math.abs(border - contentX) > RESIZE_HANDLE_TOLERANCE) {
35 1 : return null;
36 : }
37 1 : const stickyColumnWidth = offsets[1] - offsets[0];
38 1 : const borderViewportX = border - (contentX - viewportX);
39 1 : if (borderViewportX <= stickyColumnWidth) {
40 1 : return null;
41 : }
42 1 : return lo;
43 1 : }
44 :
45 : /// A column border that is being dragged
46 : interface ColumnResizeDrag {
47 : /// The pointer that grabbed the border
48 : pointerId: number;
49 : /// The field name of the resized column
50 : fieldName: string;
51 : /// The pointer position when grabbing the border
52 : startX: number;
53 : /// The column width when grabbing the border
54 : startWidth: number;
55 : }
56 :
57 : /// A column width that waits for the next animation frame
58 : interface PendingColumnWidth {
59 : /// The field name of the resized column
60 : fieldName: string;
61 : /// The new column width
62 : width: number;
63 : }
64 :
65 : /// The grid element that scrolls the cells
66 : interface GridApi {
67 : element: HTMLDivElement | null;
68 : }
69 :
70 : /// The pointer handlers of the grid container
71 : export interface DataTableColumnResizeHandlers {
72 : onPointerDownCapture: React.PointerEventHandler<HTMLDivElement>;
73 : onPointerMove: React.PointerEventHandler<HTMLDivElement>;
74 : onPointerUp: React.PointerEventHandler<HTMLDivElement>;
75 : onPointerCancel: React.PointerEventHandler<HTMLDivElement>;
76 : onLostPointerCapture: React.PointerEventHandler<HTMLDivElement>;
77 : onPointerLeave: React.PointerEventHandler<HTMLDivElement>;
78 : onClickCapture: React.MouseEventHandler<HTMLDivElement>;
79 : onDoubleClickCapture: React.MouseEventHandler<HTMLDivElement>;
80 : }
81 :
82 : /// Resize columns by dragging the right border of any cell, double-clicking a border restores the width.
83 : /// The grid container hit-tests the borders against the layout, the cells don't carry any handles.
84 1 : export function useDataTableColumnResize(
85 1 : gridApi: GridApi | null,
86 1 : gridLayout: DataTableLayout,
87 1 : table: arrow.Table,
88 1 : setResizedColumnWidths: React.Dispatch<React.SetStateAction<ResizedColumnWidths>>,
89 1 : ): DataTableColumnResizeHandlers {
90 : // Read the latest layout from the stable handlers
91 1 : const layout = React.useRef(gridLayout);
92 1 : layout.current = gridLayout;
93 1 : const schema = React.useRef(table.schema);
94 1 : schema.current = table.schema;
95 :
96 1 : const drag = React.useRef<ColumnResizeDrag | null>(null);
97 1 : const suppressClick = React.useRef(false);
98 1 : const pendingWidth = React.useRef<PendingColumnWidth | null>(null);
99 1 : const pendingFrame = React.useRef<number | null>(null);
100 1 : React.useEffect(() => () => {
101 1 : if (pendingFrame.current != null) {
102 0 : cancelAnimationFrame(pendingFrame.current);
103 : }
104 1 : }, []);
105 :
106 1 : return React.useMemo(() => {
107 1 : const hitTest = (event: React.MouseEvent<HTMLDivElement>): number | null => {
108 1 : const grid = gridApi?.element;
109 1 : if (grid == null) {
110 0 : return null;
111 : }
112 1 : const bounds = grid.getBoundingClientRect();
113 1 : const viewportX = event.clientX - bounds.left;
114 1 : const viewportY = event.clientY - bounds.top;
115 : // Ignore the scrollbars
116 1 : if (viewportX > grid.clientWidth || viewportY > grid.clientHeight) {
117 0 : return null;
118 : }
119 1 : return findResizableColumn(layout.current, viewportX + grid.scrollLeft, viewportX);
120 1 : };
121 1 : const applyPendingWidth = () => {
122 1 : if (pendingFrame.current != null) {
123 1 : cancelAnimationFrame(pendingFrame.current);
124 1 : pendingFrame.current = null;
125 1 : }
126 1 : const pending = pendingWidth.current;
127 1 : pendingWidth.current = null;
128 1 : if (pending != null) {
129 1 : setResizedColumnWidths(prev => new Map(prev).set(pending.fieldName, pending.width));
130 : }
131 1 : };
132 1 : const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
133 1 : if (drag.current?.pointerId !== event.pointerId) {
134 1 : return;
135 : }
136 1 : applyPendingWidth();
137 1 : drag.current = null;
138 1 : };
139 1 : return {
140 1 : onPointerDownCapture: (event: React.PointerEvent<HTMLDivElement>) => {
141 1 : suppressClick.current = false;
142 1 : if (event.button !== 0 || event.pointerType === 'touch') {
143 1 : return;
144 : }
145 1 : const column = hitTest(event);
146 1 : if (column == null) {
147 1 : return;
148 : }
149 : // Keep the cells and plots from seeing the press, also prevents text selection
150 1 : event.preventDefault();
151 1 : event.stopPropagation();
152 1 : event.currentTarget.setPointerCapture(event.pointerId);
153 1 : const offsets = layout.current.columnXOffsets;
154 1 : const fieldId = layout.current.arrowFieldByColumnIndex[column];
155 1 : drag.current = {
156 1 : pointerId: event.pointerId,
157 1 : fieldName: schema.current.fields[fieldId].name,
158 1 : startX: event.clientX,
159 1 : startWidth: offsets[column + 1] - offsets[column],
160 1 : };
161 1 : suppressClick.current = true;
162 1 : },
163 1 : onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => {
164 1 : const active = drag.current;
165 1 : if (active == null) {
166 1 : event.currentTarget.classList.toggle(styles.column_resize_hover, hitTest(event) != null);
167 1 : return;
168 1 : }
169 1 : if (active.pointerId !== event.pointerId) {
170 1 : return;
171 : }
172 : // Coalesce the widths per frame, every width re-renders the visible cells
173 1 : pendingWidth.current = {
174 1 : fieldName: active.fieldName,
175 1 : width: Math.max(MIN_RESIZED_COLUMN_WIDTH, active.startWidth + event.clientX - active.startX),
176 1 : };
177 1 : if (pendingFrame.current == null) {
178 1 : pendingFrame.current = requestAnimationFrame(() => {
179 0 : pendingFrame.current = null;
180 0 : applyPendingWidth();
181 1 : });
182 : }
183 1 : },
184 1 : onPointerUp: endDrag,
185 1 : onPointerCancel: endDrag,
186 1 : onLostPointerCapture: endDrag,
187 1 : onPointerLeave: (event: React.PointerEvent<HTMLDivElement>) => {
188 1 : if (drag.current == null) {
189 1 : event.currentTarget.classList.remove(styles.column_resize_hover);
190 : }
191 1 : },
192 1 : onClickCapture: (event: React.MouseEvent<HTMLDivElement>) => {
193 : // A press on a border neither opens cells nor sorts columns
194 1 : if (suppressClick.current) {
195 1 : suppressClick.current = false;
196 1 : event.preventDefault();
197 1 : event.stopPropagation();
198 1 : }
199 1 : },
200 1 : onDoubleClickCapture: (event: React.MouseEvent<HTMLDivElement>) => {
201 1 : const column = hitTest(event);
202 1 : if (column == null) {
203 0 : return;
204 : }
205 1 : event.preventDefault();
206 1 : event.stopPropagation();
207 1 : const fieldName = schema.current.fields[layout.current.arrowFieldByColumnIndex[column]].name;
208 1 : setResizedColumnWidths(prev => {
209 1 : if (!prev.has(fieldName)) {
210 1 : return prev;
211 : }
212 1 : const next = new Map(prev);
213 1 : next.delete(fieldName);
214 1 : return next;
215 1 : });
216 1 : },
217 1 : };
218 1 : }, [gridApi, setResizedColumnWidths]);
219 1 : }
|