better column dragging for virtualized tables

This commit is contained in:
its-a-feature
2026-05-01 15:50:16 -05:00
parent 85d6aa0472
commit 749f37dec9
5 changed files with 195 additions and 126 deletions
@@ -1,59 +0,0 @@
import React, { useState } from 'react';
import {classes} from './styles';
import Draggable from 'react-draggable';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DragHandleIcon from '@mui/icons-material/DragHandle';
const DraggableHandles = React.forwardRef(({ height, rowHeight, width, minColumnWidth, columnWidths, onStop }, ref) => {
const [isDragging, setIsDragging] = useState(-1);
const nodeRef = React.useRef(null);
return (
<div
ref={ref}
className={classes.draggableHandlesContainer}
style={{
height: height,
width: width,
pointerEvents: isDragging >= 0 ? 'initial' : 'none',
}}>
{columnWidths.map((_, i) => {
// leftOffset is the sum of the width of all columns left of i
const leftOffset = columnWidths.slice(0, i).reduce((a, b) => a + b, 0);
return (
<Draggable
key={i}
nodeRef={nodeRef}
axis='x'
bounds={{
left: minColumnWidth - columnWidths[i],
right: Number.POSITIVE_INFINITY,
top: 0,
bottom: 0,
}}
offsetParent={document.body}
defaultPosition={{x: 0, y: 0}}
position={{x: 0, y: 0}}
onStart={() => {
setIsDragging(i);
}}
onStop={(e, data) => {
setIsDragging(-1);
onStop(data.x, i);
}}>
<DragHandleIcon
ref={nodeRef}
className={isDragging === i ? classes.draggableHandlesClickAreaSelected : classes.draggableHandlesClickArea}
style={{
left: leftOffset + columnWidths[i] - 1 - 7,
rotate: isDragging === i ? "0deg" : "90deg",
}}>
</DragHandleIcon>
</Draggable>
);
})}
</div>
);
});
export default DraggableHandles;
@@ -14,6 +14,8 @@ const HeaderCell = ({
sortIndicatorIndex,
sortDirection,
headerNameKey = "name",
isResizing = false,
onResizePointerDown = () => {},
VariableSizeGridProps: { style, rowIndex, columnIndex, data },
}) => {
const dropdownAnchorRef = React.useRef(null);
@@ -33,6 +35,29 @@ const HeaderCell = ({
},
[onDoubleClick, columnIndex]
);
const handleResizePointerDown = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onResizePointerDown(event, columnIndex);
},
[onResizePointerDown, columnIndex]
);
const handleResizeDoubleClick = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onDoubleClick(event, columnIndex);
},
[onDoubleClick, columnIndex]
);
const stopResizeClickPropagation = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
},
[]
);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
const handleContextClick = useCallback(
(event) => {
@@ -71,6 +96,17 @@ const HeaderCell = ({
setOpenContextMenu={setOpenContextMenu} handleMenuItemClick={handleMenuItemClick}
/>
</Box>
{!item.disableResize &&
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${item[headerNameKey]} column`}
className={`${classes.headerResizeHandle} ${isResizing ? classes.headerResizeHandleActive : ""}`}
onPointerDown={handleResizePointerDown}
onDoubleClick={handleResizeDoubleClick}
onClick={stopResizeClickPropagation}
/>
}
</div>
);
};
@@ -5,7 +5,6 @@ import useScrollbarSize from 'react-scrollbar-size';
import { VariableSizeGrid } from 'react-window';
import HeaderCell from './HeaderCell';
import Cell from './Cell';
import DraggableHandles from './DraggableHandles';
import {classes} from './styles';
import {GetMythicSetting, useSetMythicSetting} from "../MythicSavedUserSetting";
@@ -44,6 +43,8 @@ const innerElementType = React.forwardRef(({ children, style }, ref) => {
contextMenuOptions={HeaderCellData.contextMenuOptions}
sortIndicatorIndex={HeaderCellData.sortIndicatorIndex}
sortDirection={HeaderCellData.sortDirection}
isResizing={HeaderCellData.resizingColumnIndex === i}
onResizePointerDown={HeaderCellData.startColumnResize}
VariableSizeGridProps={{
style: {
position: 'absolute',
@@ -103,8 +104,18 @@ const ResizableGridWrapper = ({
}));
const gridUUID = React.useMemo( () => GetShortRandomString(), []);
const gridRef = useRef(null);
const dragHandlesRef = useRef(null);
const columnWidthsRef = useRef(columnWidths);
const lastResetColumnIndexRef = useRef(0);
const activeResizeRef = useRef(null);
const resizeFrameRef = useRef(null);
const cleanupResizeListenersRef = useRef(null);
const [resizingColumnIndex, setResizingColumnIndex] = useState(-1);
const [updateSetting] = useSetMythicSetting();
const setColumnWidthsAndRef = useCallback( (newColumnWidths, resetColumnIndex=0) => {
lastResetColumnIndexRef.current = resetColumnIndex;
columnWidthsRef.current = newColumnWidths;
setColumnWidths(newColumnWidths);
}, []);
const getColumnWidth = useCallback(
(index) => {
return columnWidths[index] || MIN_COLUMN_WIDTH;
@@ -146,35 +157,118 @@ const ResizableGridWrapper = ({
}
//updatedColumnWidths[updatedWidthIndex] += totalWidthDiff;
}
setColumnWidths(updatedColumnWidths);
setColumnWidthsAndRef(updatedColumnWidths);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
}
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current, setColumnWidthsAndRef]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
gridRef.current.resetAfterColumnIndex(0, true);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: columnWidths});
if(gridRef.current){
gridRef.current.resetAfterColumnIndex(lastResetColumnIndexRef.current, true);
}
}, [columnWidths]);
/* Event Handlers */
const resizeColumn = useCallback( (x, columnIndex) => {
const updatedColumnWidths = columnWidths.map((columnWidth, index) => {
if (columnIndex === index) {
return Math.floor(Math.max(columnWidth + x, MIN_COLUMN_WIDTH));
}
return Math.floor(columnWidth);
});
setColumnWidths(updatedColumnWidths);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
const startColumnResize = useCallback( (event, columnIndex) => {
if(columns[columnIndex]?.disableResize){
return;
}
}, [columnWidths]);
const initialWidths = columnWidthsRef.current.map( (columnWidth) => Math.floor(columnWidth || MIN_COLUMN_WIDTH));
const startClientX = event.clientX;
const startWidth = initialWidths[columnIndex] || MIN_COLUMN_WIDTH;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
const applyResizeWidth = (clientX) => {
const activeResize = activeResizeRef.current;
if(!activeResize){
return;
}
activeResize.latestClientX = clientX;
if(resizeFrameRef.current !== null){
return;
}
resizeFrameRef.current = window.requestAnimationFrame(() => {
resizeFrameRef.current = null;
const currentResize = activeResizeRef.current;
if(!currentResize){
return;
}
const resizeDelta = currentResize.latestClientX - currentResize.startClientX;
const nextColumnWidth = Math.floor(Math.max(currentResize.startWidth + resizeDelta, MIN_COLUMN_WIDTH));
const updatedColumnWidths = currentResize.initialWidths.map( (columnWidth, index) => {
return currentResize.columnIndex === index ? nextColumnWidth : columnWidth;
});
setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
});
};
const cleanupResizeListeners = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
cleanupResizeListenersRef.current = null;
};
const finishResize = (clientX) => {
const currentResize = activeResizeRef.current;
if(!currentResize){
cleanupResizeListeners();
return;
}
if(resizeFrameRef.current !== null){
window.cancelAnimationFrame(resizeFrameRef.current);
resizeFrameRef.current = null;
}
const resizeDelta = clientX - currentResize.startClientX;
const nextColumnWidth = Math.floor(Math.max(currentResize.startWidth + resizeDelta, MIN_COLUMN_WIDTH));
const updatedColumnWidths = currentResize.initialWidths.map( (columnWidth, index) => {
return currentResize.columnIndex === index ? nextColumnWidth : columnWidth;
});
activeResizeRef.current = null;
setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
}
setResizingColumnIndex(-1);
cleanupResizeListeners();
};
function handlePointerMove(pointerEvent){
pointerEvent.preventDefault();
applyResizeWidth(pointerEvent.clientX);
}
function handlePointerUp(pointerEvent){
pointerEvent.preventDefault();
finishResize(pointerEvent.clientX);
}
event.currentTarget.setPointerCapture?.(event.pointerId);
activeResizeRef.current = {
columnIndex,
initialWidths,
latestClientX: startClientX,
startClientX,
startWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setResizingColumnIndex(columnIndex);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
cleanupResizeListenersRef.current = cleanupResizeListeners;
}, [columns, name, setColumnWidthsAndRef, updateSetting]);
useEffect(() => {
return () => {
cleanupResizeListenersRef.current?.();
activeResizeRef.current = null;
if(resizeFrameRef.current !== null){
window.cancelAnimationFrame(resizeFrameRef.current);
resizeFrameRef.current = null;
}
};
}, []);
const autosizeColumn = useCallback( ({columnIndex}) => {
if(columns[columnIndex].disableDoubleClick){
return
@@ -264,9 +358,12 @@ const ResizableGridWrapper = ({
}
}
if(updatedValues){
setColumnWidths(() => updatedColumnWidths);
setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
}
}
}, [columnWidths]);
}, [columnWidths, columns, items, name, setColumnWidthsAndRef, updateSetting]);
useEffect( () => {
if(callbackTableGridRef){
@@ -285,14 +382,10 @@ const ResizableGridWrapper = ({
"sortIndicatorIndex": sortIndicatorIndex,
"sortDirection": sortDirection,
"getColumnWidth": getColumnWidth,
"resizingColumnIndex": resizingColumnIndex,
"startColumnResize": startColumnResize,
"itemsWithHeader": itemsWithHeader,
};
const localOnScroll = React.useCallback( ({scrollLeft}) => {
if (dragHandlesRef.current) {
dragHandlesRef.current.scrollTo({ left: scrollLeft });
}
}, [dragHandlesRef.current])
return (
<>
<HeaderCellContext.Provider value={headerCellData}>
@@ -311,19 +404,9 @@ const ResizableGridWrapper = ({
innerElementType={innerElementType}
overscanRowCount={20}
useIsScrolling={false}
onScroll={localOnScroll}
ref={gridRef}>
{CellRenderer}
</VariableSizeGrid>
<DraggableHandles
height={AutoSizerProps.height}
rowHeight={headerRowHeight}
width={AutoSizerProps.width}
minColumnWidth={MIN_COLUMN_WIDTH}
columnWidths={columnWidths}
onStop={resizeColumn}
ref={dragHandlesRef}
/>
</HeaderCellContext.Provider>
</>
@@ -381,6 +464,7 @@ MythicResizableGrid.propTypes = {
name: PropTypes.string,
width: PropTypes.number,
disableAutosize: PropTypes.bool,
disableResize: PropTypes.bool,
disableSort: PropTypes.bool
})
).isRequired,
@@ -2,11 +2,9 @@ const PREFIX = 'MythicResizableGrid';
export const classes = {
headerCellRow: `${PREFIX}-headerCellRow`,
headerCell: `${PREFIX}-headerCell`,
headerResizeHandle: `${PREFIX}-headerResizeHandle`,
headerResizeHandleActive: `${PREFIX}-headerResizeHandleActive`,
hoveredRow: `${PREFIX}-hoveredRow`,
cell: `${PREFIX}-cell`,
cellInner: `${PREFIX}-cellInner`,
draggableHandlesContainer: `${PREFIX}-draggableHandlesContainer`,
draggableHandlesClickArea: `${PREFIX}-draggableHandlesClickArea`,
draggableHandlesClickAreaSelected: `${PREFIX}-draggableHandlesClickAreaSelected`,
draggableHandlesIndicator: `${PREFIX}-draggableHandlesIndicator`,
}
+36 -26
View File
@@ -200,6 +200,7 @@ tspan {
.MythicResizableGrid-headerCell {
display: flex;
align-items: center;
position: relative;
padding: 0 0.5em;
box-sizing: border-box;
justify-content: space-between;
@@ -215,6 +216,41 @@ tspan {
cursor: pointer;
}
}
.MythicResizableGrid-headerResizeHandle {
position: absolute;
top: 0;
right: 0;
width: 5px;
height: 100%;
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 2;
}
.MythicResizableGrid-headerResizeHandle::before {
content: "";
position: absolute;
right: 2px;
width: 0px;
height: 100%;
border-left: 1px solid ${(props) => props.theme.palette.text.main};
border-right: 1px solid ${(props) => props.theme.palette.text.main};
opacity: 0.85;
}
.MythicResizableGrid-headerResizeHandle:hover,
.MythicResizableGrid-headerResizeHandleActive {
background-color: ${(props) => props.theme.palette.info.main + "22"};
}
.MythicResizableGrid-headerResizeHandle:hover::after,
.MythicResizableGrid-headerResizeHandleActive::after {
background-color: ${(props) => props.theme.palette.info.main};
opacity: 1;
}
.MythicResizableGrid-headerResizeHandle:hover::before,
.MythicResizableGrid-headerResizeHandleActive::before {
border-color: ${(props) => props.theme.palette.info.main};
opacity: 1;
}
.MythicResizableGrid-hoveredRow {
background-color: ${(props) => props.theme.tableHover + "CC"};
}
@@ -233,32 +269,6 @@ tspan {
overflow: hidden;
text-overflow: ellipsis;
}
.MythicResizableGrid-draggableHandlesContainer {
position: absolute;
top: 0;
overflow-x: hidden;
}
.MythicResizableGrid-draggableHandlesClickArea {
position: absolute;
width: 16px;
cursor: col-resize;
pointer-events: initial;
}
.MythicResizableGrid-draggableHandlesClickAreaSelected {
position: absolute;
cursor: col-resize;
width: 10px;
pointer-events: initial;
background-color: ${(props) => props.theme.palette.info.main};
opacity: 0.5;
height: 100%;
}
.MythicResizableGrid-draggableHandlesIndicator {
position: relative;
width: 10px;
color: red;
height: 100px;
}
.Toastify__toast {
word-break: break-all;
white-space: pre-wrap !important;