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 5dbc8c448..022b96896 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -1,3 +1,5 @@ +import { MouseEvent } from 'react'; + import { ItemListItem, ItemListStateActions, @@ -10,7 +12,26 @@ const handleItemClick = ( itemType: LibraryItem, internalState: ItemListStateActions, ) => { - console.log('handleItemClick', item, itemType, internalState); + if (!item) { + return; + } + + const itemListItem: ItemListItem = { + id: item.id, + itemType, + serverId: item.serverId, + }; + + // Regular click - deselect all others and select only this item + // If this item is already the only selected item, deselect it + const selectedItems = internalState.getSelected(); + const isOnlySelected = selectedItems.length === 1 && selectedItems[0].id === item.id; + + if (isOnlySelected) { + internalState.clearSelected(); + } else { + internalState.setSelected([itemListItem]); + } }; const handleItemDoubleClick = ( diff --git a/src/renderer/components/item-list/item-table-list/columns/album-artists-column.module.css b/src/renderer/components/item-list/item-table-list/columns/album-artists-column.module.css index 3ee866ed6..f9312f148 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-artists-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/album-artists-column.module.css @@ -13,6 +13,7 @@ -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: var(--theme-colors-foreground-muted); + user-select: none; } .artists-container.compact { diff --git a/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css b/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css index 9ad1642d7..28ac5e6be 100644 --- a/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css +++ b/src/renderer/components/item-list/item-table-list/columns/genre-column.module.css @@ -3,8 +3,8 @@ overflow: hidden; -webkit-line-clamp: 2; color: var(--theme-colors-foreground-muted); - user-select: none; -webkit-box-orient: vertical; + user-select: none; } .genres-container.compact { diff --git a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx index e2ed7a3a2..4dc1e08cf 100644 --- a/src/renderer/components/item-list/item-table-list/columns/title-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/title-column.tsx @@ -36,6 +36,7 @@ export const TitleColumn = (props: ItemTableListInnerColumn) => { [styles.large]: props.size === 'large', [styles.nameContainer]: true, })} + isNoSelect {...titleLinkProps} > {row} 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 98a87b8c9..a23cc4c68 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 @@ -82,7 +82,6 @@ } } -.container.data-row.row-hover-highlight-enabled:hover::before, .container.data-row.row-hover-highlight-enabled.row-hovered::before { position: absolute; top: 0; @@ -96,11 +95,35 @@ opacity: 0.7; } +.container.data-row.row-selected::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + pointer-events: none; + content: ''; + opacity: 0.7; + + @mixin dark { + background-color: lighten(var(--theme-colors-surface), 5%); + } + + @mixin light { + background-color: var(--theme-colors-surface); + } +} + .container.data-row > * { position: relative; z-index: 2; } +.container.data-row { + cursor: pointer; +} + .header-container { background: none; } 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 591bb8707..ce33248a7 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 @@ -47,8 +47,13 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { const isHeaderEnabled = !!props.enableHeader; const controls: ItemControls = { - onClick: (item, itemType) => - itemListControls.handleItemClick(item, itemType, props.internalState), + onClick: (item, itemType, event) => { + if (props.onRowClick && item) { + props.onRowClick(item, event); + } else { + itemListControls.handleItemClick(item, itemType, props.internalState); + } + }, onDoubleClick: (item, itemType) => itemListControls.handleItemDoubleClick(item, itemType, props.internalState), onFavorite: (item, itemType) => @@ -158,8 +163,13 @@ export const TableColumnTextContainer = ( }, ) => { const containerRef = useRef(null); - const isDataRow = props.enableHeader && props.rowIndex > 0; - const dataIndex = isDataRow ? props.rowIndex - 1 : props.rowIndex; + const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; + const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; + const item = isDataRow ? props.data[props.rowIndex] : null; + const isSelected = + item && typeof item === 'object' && 'id' in item + ? props.internalState.isSelected((item as any).id) + : false; useEffect(() => { if (!isDataRow || !containerRef.current) return; @@ -188,6 +198,22 @@ export const TableColumnTextContainer = ( }; }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight]); + const handleCellClick = (event: React.MouseEvent) => { + // Don't trigger row selection if clicking on interactive elements + const target = event.target as HTMLElement; + const isInteractiveElement = target.closest( + 'button, a, input, select, textarea, [role="button"]', + ); + + if (isInteractiveElement) { + return; + } + + if (isDataRow && item && props.enableSelection) { + props.controls.onClick?.(item as any, props.itemType, event); + } + }; + return (
0, [styles.withVerticalBorder]: props.enableVerticalBorders, })} data-row-index={isDataRow ? props.rowIndex : undefined} + onClick={handleCellClick} ref={containerRef} style={props.style} > @@ -239,8 +267,13 @@ export const TableColumnContainer = ( }, ) => { const containerRef = useRef(null); - const isDataRow = props.enableHeader && props.rowIndex > 0; - const dataIndex = isDataRow ? props.rowIndex - 1 : props.rowIndex; + const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; + const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; + const item = isDataRow ? props.data[props.rowIndex] : null; + const isSelected = + item && typeof item === 'object' && 'id' in item + ? props.internalState.isSelected((item as any).id) + : false; useEffect(() => { if (!isDataRow || !containerRef.current) return; @@ -269,6 +302,22 @@ export const TableColumnContainer = ( }; }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight]); + const handleCellClick = (event: React.MouseEvent) => { + // Don't trigger row selection if clicking on interactive elements + const target = event.target as HTMLElement; + const isInteractiveElement = target.closest( + 'button, a, input, select, textarea, [role="button"]', + ); + + if (isInteractiveElement) { + return; + } + + if (isDataRow && item && props.enableSelection) { + props.controls.onClick?.(item as any, props.itemType, event); + } + }; + return (
0, [styles.withVerticalBorder]: props.enableVerticalBorders, })} data-row-index={isDataRow ? props.rowIndex : undefined} + onClick={handleCellClick} ref={containerRef} style={{ ...props.containerStyle, ...props.style }} > 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 102c0d1c9..fafe4ce52 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 @@ -21,6 +21,7 @@ import styles from './item-table-list.module.css'; import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container'; import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item'; import { + ItemListItem, ItemListStateActions, useItemListState, } from '/@/renderer/components/item-list/helpers/item-list-state'; @@ -46,6 +47,7 @@ interface VirtualizedTableGridProps { itemType: LibraryItem; mergedRowRef: React.Ref; onCellsRendered?: GridProps['onCellsRendered']; + onRowClick?: (item: any, event: React.MouseEvent) => void; parsedColumns: ReturnType; pinnedLeftColumnCount: number; pinnedLeftColumnRef: React.RefObject; @@ -79,6 +81,7 @@ const VirtualizedTableGrid = React.memo( itemType, mergedRowRef, onCellsRendered, + onRowClick, parsedColumns, pinnedLeftColumnCount, pinnedLeftColumnRef, @@ -112,6 +115,7 @@ const VirtualizedTableGrid = React.memo( getRowHeight, internalState, itemType, + onRowClick, size, }), [ @@ -128,6 +132,7 @@ const VirtualizedTableGrid = React.memo( getRowHeight, internalState, itemType, + onRowClick, size, ], ); @@ -390,6 +395,7 @@ export interface TableItemProps { getRowHeight: (index: number, cellProps: TableItemProps) => number; internalState: ItemListStateActions; itemType: ItemTableListProps['itemType']; + onRowClick?: (item: any, event: React.MouseEvent) => void; size?: ItemTableListProps['size']; } @@ -894,6 +900,119 @@ export const ItemTableList = ({ const hasExpanded = internalState.hasExpanded(); + const handleRowClick = useCallback( + (item: any, event: React.MouseEvent) => { + if (!enableSelection || !item) { + return; + } + + const itemListItem: ItemListItem = { + id: item.id, + itemType, + serverId: item.serverId, + }; + + // Check if ctrl/cmd key is held for multi-selection + if (event.ctrlKey || event.metaKey) { + const isCurrentlySelected = internalState.isSelected(item.id); + + if (isCurrentlySelected) { + // Remove this item from selection + const currentSelected = internalState.getSelected(); + const filteredSelected = currentSelected.filter( + (selectedItem) => selectedItem.id !== item.id, + ); + internalState.setSelected(filteredSelected); + } else { + // Add this item to selection + const currentSelected = internalState.getSelected(); + const newSelected = [...currentSelected, itemListItem]; + internalState.setSelected(newSelected); + } + } + // Check if shift key is held for range selection + else if (event.shiftKey) { + const selectedItems = internalState.getSelected(); + const lastSelectedItem = selectedItems[selectedItems.length - 1]; + + if (lastSelectedItem) { + // Find the indices of the last selected item and current item + const lastIndex = data.findIndex( + (d) => + d && typeof d === 'object' && 'id' in d && d.id === lastSelectedItem.id, + ); + const currentIndex = data.findIndex( + (d) => d && typeof d === 'object' && 'id' in d && d.id === item.id, + ); + + if (lastIndex !== -1 && currentIndex !== -1) { + // Create range selection + const startIndex = Math.min(lastIndex, currentIndex); + const endIndex = Math.max(lastIndex, currentIndex); + + const rangeItems: ItemListItem[] = []; + for (let i = startIndex; i <= endIndex; i++) { + const rangeItem = data[i]; + if ( + rangeItem && + typeof rangeItem === 'object' && + 'id' in rangeItem && + 'serverId' in rangeItem + ) { + rangeItems.push({ + id: (rangeItem as any).id, + itemType, + serverId: (rangeItem as any).serverId, + }); + } + } + + // Toggle selection for the range + const isCurrentlySelected = internalState.isSelected(item.id); + + if (isCurrentlySelected) { + // Deselect the range + const currentSelected = internalState.getSelected(); + const filteredSelected = currentSelected.filter( + (selectedItem) => + !rangeItems.some( + (rangeItem) => rangeItem.id === selectedItem.id, + ), + ); + internalState.setSelected(filteredSelected); + } else { + // Select the range + const currentSelected = internalState.getSelected(); + const newSelected = [...currentSelected]; + rangeItems.forEach((rangeItem) => { + if (!newSelected.some((selected) => selected.id === rangeItem.id)) { + newSelected.push(rangeItem); + } + }); + internalState.setSelected(newSelected); + } + } + } else { + // No previous selection, just toggle this item + internalState.toggleSelected(itemListItem); + } + } else { + // Regular click - deselect all others and select only this item + // If this item is already the only selected item, deselect it + const selectedItems = internalState.getSelected(); + const isOnlySelected = + selectedItems.length === 1 && selectedItems[0].id === item.id; + + if (isOnlySelected) { + internalState.clearSelected(); + } else { + internalState.setSelected([itemListItem]); + } + } + }, + [data, enableSelection, internalState, itemType], + ); + const handleOnCellsRendered = useCallback( (cells: { columnStartIndex: number; @@ -1007,6 +1126,7 @@ export const ItemTableList = ({ itemType={itemType} mergedRowRef={mergedRowRef} onCellsRendered={handleOnCellsRendered} + onRowClick={handleRowClick} parsedColumns={parsedColumns} pinnedLeftColumnCount={pinnedLeftColumnCount} pinnedLeftColumnRef={pinnedLeftColumnRef} diff --git a/src/renderer/components/item-list/types.ts b/src/renderer/components/item-list/types.ts index 696bc3ac6..9a57216b5 100644 --- a/src/renderer/components/item-list/types.ts +++ b/src/renderer/components/item-list/types.ts @@ -78,6 +78,7 @@ export interface ItemListTableComponentProps extends ItemListComponentPr enableAlternateRowColors?: boolean; enableHorizontalBorders?: boolean; enableRowHoverHighlight?: boolean; + enableSelection?: boolean; enableVerticalBorders?: boolean; size?: 'compact' | 'default'; } 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 10ce03302..3fe6af83b 100644 --- a/src/renderer/features/albums/components/album-list-infinite-table.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-table.tsx @@ -25,6 +25,7 @@ export const AlbumListInfiniteTable = forwardRef enableAlternateRowColors = false, enableHorizontalBorders = false, enableRowHoverHighlight = true, + enableSelection = true, enableVerticalBorders = false, itemsPerPage = 100, query = { @@ -61,6 +62,7 @@ export const SongListInfiniteTable = forwardRef enableAlternateRowColors={enableAlternateRowColors} enableHorizontalBorders={enableHorizontalBorders} enableRowHoverHighlight={enableRowHoverHighlight} + enableSelection={enableSelection} enableVerticalBorders={enableVerticalBorders} initialTop={{ to: scrollOffset ?? 0, 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 1b10b1ad8..11956f9a4 100644 --- a/src/renderer/features/songs/components/song-list-paginated-table.tsx +++ b/src/renderer/features/songs/components/song-list-paginated-table.tsx @@ -21,6 +21,7 @@ export const SongListPaginatedTable = forwardRef