add draggable table column resize

This commit is contained in:
jeffvli
2025-11-14 10:58:34 -08:00
parent 31a2fdbcb6
commit 4c92da9ab5
16 changed files with 272 additions and 5 deletions
@@ -6,12 +6,18 @@ import { ItemListStateItemWithRequiredProperties } from '/@/renderer/components/
import { DefaultItemControlProps, ItemControls } from '/@/renderer/components/item-list/types';
import { usePlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem, QueueSong } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
import { Play, TableColumn } from '/@/shared/types/types';
export const useDefaultItemListControls = () => {
interface UseDefaultItemListControlsArgs {
onColumnResized?: (columnId: TableColumn, width: number) => void;
}
export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs) => {
const player = usePlayerContext();
const navigate = useNavigate();
const { onColumnResized } = args || {};
const controls: ItemControls = useMemo(() => {
return {
onClick: ({ event, internalState, item }: DefaultItemControlProps) => {
@@ -147,6 +153,10 @@ export const useDefaultItemListControls = () => {
}
},
onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => {
onColumnResized?.(columnId, width);
},
onDoubleClick: ({ internalState, item, itemType }: DefaultItemControlProps) => {
if (!item || !internalState) {
return;
@@ -239,7 +249,7 @@ export const useDefaultItemListControls = () => {
player.setRating(item._serverId, [item.id], itemType, newRating);
},
};
}, [player, navigate]);
}, [onColumnResized, navigate, player]);
return controls;
};
@@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store';
import { ItemListKey, TableColumn } from '/@/shared/types/types';
interface UseItemListColumnResizeProps {
itemListKey: ItemListKey;
}
export const useItemListColumnResize = ({ itemListKey }: UseItemListColumnResizeProps) => {
const { setList } = useSettingsStoreActions();
const columns = useSettingsStore((state) => state.lists[itemListKey]?.table.columns);
const handleColumnResized = useCallback(
(columnId: TableColumn, width: number) => {
if (!columns) return;
const updatedColumns = columns.map((column) =>
column.id === columnId ? { ...column, width } : column,
);
setList(itemListKey, {
table: {
columns: updatedColumns,
},
});
},
[columns, itemListKey, setList],
);
return { handleColumnResized };
};
@@ -167,6 +167,7 @@
}
.header-container {
position: relative;
background: none;
}
@@ -262,3 +263,58 @@
.container.data-row.row-hovered :global(.hide-on-hover) {
display: none;
}
.resize-handle {
position: absolute;
top: 8px;
bottom: 8px;
z-index: 10;
width: 8px;
margin-right: -4px;
cursor: col-resize;
opacity: 0;
transition: opacity 0.3s ease;
}
.resize-handle::before {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2px;
content: '';
background-color: transparent;
transition: background-color 0.15s ease;
}
.header-container:hover .resize-handle {
opacity: 1;
}
.header-container:hover .resize-handle::before {
background-color: var(--theme-colors-border);
}
.resize-handle-left {
left: 0;
margin-right: 0;
margin-left: -4px;
}
.resize-handle-left::before {
right: auto;
left: 0;
}
.resize-handle-right {
right: 0;
margin-right: 0;
}
.resize-handle-dragging {
opacity: 1;
}
.resize-handle:hover {
opacity: 1;
}
@@ -1,6 +1,6 @@
import { useMergedRef } from '@mantine/hooks';
import clsx from 'clsx';
import React, { CSSProperties, ReactNode, useEffect, useRef } from 'react';
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { CellComponentProps } from 'react-window-v2';
import styles from './item-table-list-column.module.css';
@@ -716,6 +716,82 @@ export const TableColumnContainer = (
);
};
interface ColumnResizeHandleProps {
columnId: TableColumn;
initialWidth: number;
onResize: (columnId: TableColumn, width: number) => void;
side: 'left' | 'right';
}
const ColumnResizeHandle = ({
columnId,
initialWidth,
onResize,
side,
}: ColumnResizeHandleProps) => {
const [isDragging, setIsDragging] = useState(false);
const handleRef = useRef<HTMLDivElement>(null);
const startWidthRef = useRef<number>(initialWidth);
const startXRef = useRef<number>(0);
const finalWidthRef = useRef<number>(initialWidth);
// Update the ref when initialWidth changes (but not during drag)
useEffect(() => {
if (!isDragging) {
startWidthRef.current = initialWidth;
}
}, [initialWidth, isDragging]);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (event: MouseEvent) => {
const deltaX = event.clientX - startXRef.current;
const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000);
finalWidthRef.current = newWidth;
};
const handleMouseUp = () => {
setIsDragging(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
onResize(columnId, finalWidthRef.current);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, columnId, onResize]);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
startWidthRef.current = initialWidth;
startXRef.current = event.clientX;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
return (
<div
className={clsx(styles.resizeHandle, {
[styles.resizeHandleDragging]: isDragging,
[styles.resizeHandleLeft]: side === 'left',
[styles.resizeHandleRight]: side === 'right',
})}
onMouseDown={handleMouseDown}
ref={handleRef}
/>
);
};
export const TableColumnHeaderContainer = (
props: ItemTableListColumn & {
className?: string;
@@ -724,6 +800,14 @@ export const TableColumnHeaderContainer = (
type: TableColumn;
},
) => {
const columnConfig = props.columns[props.columnIndex];
// Use the actual rendered width from style if available, otherwise fall back to config width
const currentWidth = (props.style?.width as number | undefined) || columnConfig.width;
const handleResize = (columnId: TableColumn, width: number) => {
props.controls.onColumnResized?.({ columnId, width });
};
return (
<Flex
className={clsx(styles.container, styles.headerContainer, props.containerClassName, {
@@ -745,6 +829,14 @@ export const TableColumnHeaderContainer = (
>
{columnLabelMap[props.type]}
</Text>
{!columnConfig.autoSize && props.enableColumnResize && (
<ColumnResizeHandle
columnId={props.type}
initialWidth={currentWidth}
onResize={handleResize}
side="right"
/>
)}
</Flex>
);
};
@@ -40,6 +40,7 @@ import {
usePlayerContext,
} from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
import { TableColumn } from '/@/shared/types/types';
/**
* Type guard to check if an item has the required properties (id and serverId)
@@ -89,6 +90,7 @@ interface VirtualizedTableGridProps {
controls: ItemControls;
data: unknown[];
enableAlternateRowColors: boolean;
enableColumnResize: boolean;
enableDrag?: boolean;
enableExpansion: boolean;
enableHeader: boolean;
@@ -126,6 +128,7 @@ const VirtualizedTableGrid = React.memo(
controls,
data,
enableAlternateRowColors,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
@@ -166,6 +169,7 @@ const VirtualizedTableGrid = React.memo(
controls,
data: enableHeader ? [null, ...data] : data,
enableAlternateRowColors,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
@@ -187,6 +191,7 @@ const VirtualizedTableGrid = React.memo(
enableHeader,
data,
enableAlternateRowColors,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
@@ -470,6 +475,7 @@ export interface TableItemProps {
controls: ItemControls;
data: ItemTableListProps['data'];
enableAlternateRowColors?: ItemTableListProps['enableAlternateRowColors'];
enableColumnResize?: boolean;
enableDrag?: ItemTableListProps['enableDrag'];
enableExpansion?: ItemTableListProps['enableExpansion'];
enableHeader?: ItemTableListProps['enableHeader'];
@@ -509,6 +515,7 @@ interface ItemTableListProps {
type: 'index' | 'offset';
};
itemType: LibraryItem;
onColumnResized?: (columnId: TableColumn, width: number) => void;
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void;
onScrollEnd?: (offset: number, internalState: ItemListStateActions) => void;
ref?: Ref<ItemListHandle>;
@@ -535,6 +542,7 @@ export const ItemTableList = ({
headerHeight = 40,
initialTop,
itemType,
onColumnResized,
onRangeChanged,
onScrollEnd,
ref,
@@ -1363,7 +1371,9 @@ export const ItemTableList = ({
handleRef.current = imperativeHandle;
}, [imperativeHandle]);
const controls = useDefaultItemListControls();
const controls = useDefaultItemListControls({
onColumnResized,
});
return (
<div
@@ -1380,6 +1390,7 @@ export const ItemTableList = ({
controls={controls}
data={data}
enableAlternateRowColors={enableAlternateRowColors}
enableColumnResize={!!onColumnResized}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableHeader={enableHeader}
@@ -18,6 +18,7 @@ export interface DefaultItemControlProps {
export interface ItemControls {
onClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void;
onColumnResized?: ({ columnId, width }: { columnId: TableColumn; width: number }) => void;
onDoubleClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void;
onExpand?: ({ internalState, item, itemType }: DefaultItemControlProps) => void;
onFavorite?: ({