LCOV - code coverage report
Current view: top level - dashql-app/src/views/query_result - data_table_column_resize.ts Coverage Total Hit
Test: DashQL 915828b Lines: 95.0 % 139 132
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1