From c66c38b0192e16358b1d26673ce4b83f28baecab Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 14 Nov 2025 10:58:34 -0800 Subject: [PATCH] add draggable table column resize --- .../item-list/helpers/item-list-controls.ts | 16 +++- .../helpers/use-item-list-column-resize.ts | 32 +++++++ .../item-table-list-column.module.css | 56 +++++++++++ .../item-table-list-column.tsx | 94 ++++++++++++++++++- .../item-table-list/item-table-list.tsx | 13 ++- src/renderer/components/item-list/types.ts | 1 + .../components/album-list-infinite-table.tsx | 6 ++ .../components/album-list-paginated-table.tsx | 7 ++ .../album-artist-list-infinite-table.tsx | 6 ++ .../album-artist-list-paginated-table.tsx | 7 ++ .../components/artist-list-infinite-table.tsx | 6 ++ .../artist-list-paginated-table.tsx | 7 ++ .../components/genre-list-infinite-table.tsx | 6 ++ .../components/genre-list-paginated-table.tsx | 7 ++ .../components/song-list-infinite-table.tsx | 6 ++ .../components/song-list-paginated-table.tsx | 7 ++ 16 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/item-list/helpers/use-item-list-column-resize.ts diff --git a/src/renderer/components/item-list/helpers/item-list-controls.ts b/src/renderer/components/item-list/helpers/item-list-controls.ts index 0698543c1..99ae9105a 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -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; }; diff --git a/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts b/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts new file mode 100644 index 000000000..1b79edf9e --- /dev/null +++ b/src/renderer/components/item-list/helpers/use-item-list-column-resize.ts @@ -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 }; +}; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index b9c367067..576718fae 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -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; +} diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 5682944d8..808ab5855 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -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(null); + const startWidthRef = useRef(initialWidth); + const startXRef = useRef(0); + const finalWidthRef = useRef(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) => { + 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 ( +
+ ); +}; + 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 ( {columnLabelMap[props.type]} + {!columnConfig.autoSize && props.enableColumnResize && ( + + )} ); }; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index c25b26114..4b712d955 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -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; @@ -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 (
void; + onColumnResized?: ({ columnId, width }: { columnId: TableColumn; width: number }) => void; onDoubleClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; onExpand?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; onFavorite?: ({ diff --git a/src/renderer/features/albums/components/album-list-infinite-table.tsx b/src/renderer/features/albums/components/album-list-infinite-table.tsx index 74248401e..fba010e20 100644 --- a/src/renderer/features/albums/components/album-list-infinite-table.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-table.tsx @@ -3,6 +3,7 @@ import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; @@ -60,6 +61,10 @@ export const AlbumListInfiniteTable = forwardRef {} @@ -63,6 +65,10 @@ export const AlbumListPaginatedTable = forwardRef {} @@ -64,6 +66,10 @@ export const AlbumArtistListPaginatedTable = forwardRef {} @@ -63,6 +65,10 @@ export const ArtistListPaginatedTable = forwardRef {} @@ -63,6 +65,10 @@ export const GenreListPaginatedTable = forwardRef enabled: saveScrollOffset, }); + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.SONG, + }); + return ( type: 'offset', }} itemType={LibraryItem.SONG} + onColumnResized={handleColumnResized} onRangeChanged={onRangeChanged} onScrollEnd={handleOnScrollEnd} ref={ref} diff --git a/src/renderer/features/songs/components/song-list-paginated-table.tsx b/src/renderer/features/songs/components/song-list-paginated-table.tsx index 195985449..a890b769b 100644 --- a/src/renderer/features/songs/components/song-list-paginated-table.tsx +++ b/src/renderer/features/songs/components/song-list-paginated-table.tsx @@ -3,6 +3,7 @@ import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; @@ -11,6 +12,7 @@ import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table import { ItemListTableComponentProps } from '/@/renderer/components/item-list/types'; import { songsQueries } from '/@/renderer/features/songs/api/songs-api'; import { LibraryItem, SongListQuery, SongListSort, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; interface SongListPaginatedTableProps extends ItemListTableComponentProps {} @@ -58,6 +60,10 @@ export const SongListPaginatedTable = forwardRef