import { attachClosestEdge, type Edge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; import clsx from 'clsx'; import React, { CSSProperties, memo, ReactElement, ReactNode, useEffect, useRef, useState, } from 'react'; import { CellComponentProps } from 'react-window-v2'; import styles from './item-table-list-column.module.css'; import i18n from '/@/i18n/i18n'; import { useItemSelectionState } from '/@/renderer/components/item-list/helpers/item-list-state'; import { isNoHorizontalPaddingColumn } from '/@/renderer/components/item-list/item-detail-list/utils'; import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column'; import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column'; import { AlbumColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-column'; import { AlbumGroupColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-group-column'; import { ArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/artists-column'; import { ComposerColumn } from '/@/renderer/components/item-list/item-table-list/columns/composer-column'; import { CountColumn } from '/@/renderer/components/item-list/item-table-list/columns/count-column'; import { AbsoluteDateColumn, DateColumn, RelativeDateColumn, } from '/@/renderer/components/item-list/item-table-list/columns/date-column'; import { DefaultColumn } from '/@/renderer/components/item-list/item-table-list/columns/default-column'; import { DurationColumn } from '/@/renderer/components/item-list/item-table-list/columns/duration-column'; import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list/columns/favorite-column'; import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column'; import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column'; import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column'; import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column'; import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column'; import { PlaylistReorderColumn } from '/@/renderer/components/item-list/item-table-list/columns/playlist-reorder-column'; import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column'; import { ReleaseYearColumn } from '/@/renderer/components/item-list/item-table-list/columns/release-year-column'; import { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column'; import { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column'; import { TextColumn } from '/@/renderer/components/item-list/item-table-list/columns/text-column'; import { TitleArtistColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-artist-column'; import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column'; import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column'; import { TrackDateColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-date-column'; import { TrackNumberColumn } from '/@/renderer/components/item-list/item-table-list/columns/track-number-column'; import { YearColumn } from '/@/renderer/components/item-list/item-table-list/columns/year-column'; import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state'; import { TableItemProps, TableItemSize, } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { useItemTableListColumnResizeLive } from '/@/renderer/components/item-list/item-table-list/item-table-list-context'; import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types'; import { Flex } from '/@/shared/components/flex/flex'; import { Icon } from '/@/shared/components/icon/icon'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Text } from '/@/shared/components/text/text'; import { useDoubleClick } from '/@/shared/hooks/use-double-click'; import { useMergedRef } from '/@/shared/hooks/use-merged-ref'; import { LibraryItem } from '/@/shared/types/domain-types'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { TableColumn } from '/@/shared/types/types'; export interface ItemTableListColumn extends CellComponentProps { columnType?: TableColumn; } export interface ItemTableListInnerColumn extends ItemTableListColumn { controls: ItemControls; dragRef?: null | React.Ref; isDraggedOver?: 'bottom' | 'top' | null; isDragging?: boolean; type: TableColumn; } const ItemTableListColumnBase = (props: ItemTableListColumn) => { const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn); const isHeaderEnabled = !!props.enableHeader; const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true; const item = isDataRow ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) : null; const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; const itemType = (item as unknown as { _itemType?: LibraryItem })?._itemType || props.itemType; // Check if this row should render a group header (must be before conditional returns) // Group headers need to be rendered consistently across all grids (pinned left, main, pinned right) // to maintain proper styling and row heights let groupHeader: 'GROUP_HEADER' | null | ReactElement = null; if (props.groups && isDataRow && props.groups.length > 0) { const groupInfo = props.groupHeaderInfoByRowIndex?.get(props.rowIndex); const group = groupInfo ? props.groups[groupInfo.groupIndex] : undefined; if (groupInfo && group) { // Determine where to render the group header content: // - If pinned left columns exist, render in the first pinned left column // - Otherwise, render in the first column of the main grid const hasPinnedLeftColumns = (props.pinnedLeftColumnCount || 0) > 0; const isFirstPinnedLeftColumn = props.columnIndex === 0 && hasPinnedLeftColumns; const isMainGridFirstColumn = !hasPinnedLeftColumns && (props.columnIndex === (props.pinnedLeftColumnCount || 0) || (props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) === 0)); // Render group header content in the first pinned left column (if exists) or first main grid column if (isFirstPinnedLeftColumn || isMainGridFirstColumn) { groupHeader = group.render({ data: props.getGroupRenderData?.() ?? [], groupIndex: groupInfo.groupIndex, index: props.rowIndex, internalState: props.internalState, startDataIndex: groupInfo.startDataIndex, }); } else { // For other columns, mark as group header row for styled rendering groupHeader = 'GROUP_HEADER'; } } } const { dragRef, isDraggedOver, isDragging } = useItemDragDropState({ enableDrag: !!props.enableDrag, internalState: props.internalState, isDataRow, item, itemType: props.itemType, playerContext: props.playerContext, playlistId: props.playlistId, }); const controls = props.controls; const dragProps = { dragRef: shouldEnableDrag ? dragRef : null, isDraggedOver: isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null, isDragging, }; if (isHeaderEnabled && props.rowIndex === 0) { return ; } // Render group header if this row should have one if (groupHeader) { if (groupHeader === 'GROUP_HEADER') { // For non-first columns (pinned left, other main columns, pinned right), // render a styled cell that matches the group header styling // This ensures consistent row heights and styling across all grids return
; } // Render the group header spanning full table width // If rendering in pinned left column, extend right to cover all columns // If rendering in main grid, extend left to cover pinned columns const pinnedLeftWidth = props.pinnedLeftColumnWidths?.reduce((sum, width) => sum + width, 0) || 0; // Determine if we're rendering in the first pinned left column const isFirstPinnedLeftColumn = props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) > 0; if (isFirstPinnedLeftColumn) { return (
{groupHeader}
); } // For main grid, use negative margin to extend left return (
0 ? `-${pinnedLeftWidth}px` : 0, }} > {groupHeader}
); } if (type === TableColumn.LAYOUT_FILL) { return ( {null} ); } if (itemType !== LibraryItem.FOLDER) { switch (type) { case TableColumn.ACTIONS: case TableColumn.SKIP: return ; case TableColumn.ALBUM: return ; case TableColumn.ALBUM_ARTIST: return ( ); case TableColumn.ALBUM_COUNT: case TableColumn.PLAY_COUNT: case TableColumn.SONG_COUNT: return ; case TableColumn.ALBUM_GROUP: return ( ); case TableColumn.ARTIST: return ; case TableColumn.BIOGRAPHY: case TableColumn.COMMENT: return ; case TableColumn.BIT_DEPTH: case TableColumn.BIT_RATE: case TableColumn.BPM: case TableColumn.CHANNELS: case TableColumn.DISC_NUMBER: case TableColumn.SAMPLE_RATE: return ; case TableColumn.COMPOSER: return ; case TableColumn.DATE: return ( ); case TableColumn.DATE_ADDED: return ; case TableColumn.DURATION: return ; case TableColumn.GENRE: return ; case TableColumn.GENRE_BADGE: return ( ); case TableColumn.IMAGE: return ; case TableColumn.LAST_PLAYED: return ( ); case TableColumn.PATH: return ; case TableColumn.PLAYLIST_REORDER: return ; case TableColumn.RELEASE_DATE: return ( ); case TableColumn.RELEASE_YEAR: return ( ); case TableColumn.ROW_INDEX: return ; case TableColumn.SIZE: return ; case TableColumn.TITLE: return ; case TableColumn.TITLE_ARTIST: return ( ); case TableColumn.TITLE_COMBINED: return ( ); case TableColumn.TRACK_NUMBER: return ( ); case TableColumn.USER_FAVORITE: return ; case TableColumn.USER_RATING: return ; case TableColumn.YEAR: return ; default: return ; } } switch (type) { case TableColumn.ACTIONS: return ; case TableColumn.IMAGE: return ; case TableColumn.ROW_INDEX: return ; case TableColumn.TITLE: return ; case TableColumn.TITLE_ARTIST: return ; case TableColumn.TITLE_COMBINED: return ( ); default: return ; } }; export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nextProps) => { const prevItem = prevProps.getRowItem?.(prevProps.rowIndex); const nextItem = nextProps.getRowItem?.(nextProps.rowIndex); return ( prevProps.rowIndex === nextProps.rowIndex && prevProps.columnIndex === nextProps.columnIndex && prevProps.data === nextProps.data && prevProps.columns === nextProps.columns && prevProps.style === nextProps.style && prevProps.columnType === nextProps.columnType && prevProps.itemType === nextProps.itemType && prevProps.enableHeader === nextProps.enableHeader && prevProps.enableDrag === nextProps.enableDrag && prevProps.groups === nextProps.groups && prevProps.groupHeaderInfoByRowIndex === nextProps.groupHeaderInfoByRowIndex && prevProps.pinnedLeftColumnCount === nextProps.pinnedLeftColumnCount && prevProps.pinnedLeftColumnWidths === nextProps.pinnedLeftColumnWidths && prevProps.size === nextProps.size && prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors && prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders && prevProps.enableVerticalBorders === nextProps.enableVerticalBorders && prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight && prevProps.enableSelection === nextProps.enableSelection && prevProps.enableColumnResize === nextProps.enableColumnResize && prevProps.enableColumnReorder === nextProps.enableColumnReorder && prevProps.cellPadding === nextProps.cellPadding && prevProps.playlistId === nextProps.playlistId && prevItem === nextItem ); }); const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_ARTIST, TableColumn.TITLE_COMBINED]; /** * Stable content-height estimate for album-group info (title + metadata + controls). * Used by the virtualizer before a group header mounts/measures, so scrolling in * new groups does not jump when measured height is written later. * Keep in sync with album-group-header styles (title line-clamp, metadata xs, controls). */ export function estimateAlbumGroupContentHeight({ metadataRowCount, showControls, }: { metadataRowCount: number; showControls: boolean; }): number { // Prefer a single title line for the pre-measure floor. Wrapped titles are // picked up by the measured content height after mount. const TITLE_LINE_HEIGHT = 20; const METADATA_LINE_HEIGHT = 18; const CONTROLS_HEIGHT = 38; return ( TITLE_LINE_HEIGHT + Math.max(0, metadataRowCount) * METADATA_LINE_HEIGHT + (showControls ? CONTROLS_HEIGHT : 0) ); } /** Stable key for album-group content heights (survives row moves; not row index). */ export function getAlbumGroupHeightKey(item: unknown, groupRowCount?: number): string | undefined { if (!item || typeof item !== 'object') return undefined; let itemKey: string | undefined; if ('_uniqueId' in item && typeof (item as { _uniqueId?: unknown })._uniqueId === 'string') { itemKey = (item as { _uniqueId: string })._uniqueId; } else if ('id' in item && typeof (item as { id?: unknown }).id === 'string') { itemKey = (item as { id: string }).id; } if (!itemKey) return undefined; if (groupRowCount === undefined) return itemKey; return `${itemKey}:${groupRowCount}`; } // Counts how many consecutive rows belong to the same album group as `rowIndex`. export function getAlbumGroupRowCount( rowIndex: number, getRowItem: ((index: number) => unknown) | undefined, enableHeader: boolean | undefined, dataLength: number, ): number { const item = getRowItem?.(rowIndex) as null | undefined | { album?: string }; if (!item?.album) return 1; const firstDataRow = enableHeader ? 1 : 0; const maxRow = enableHeader ? dataLength + 1 : dataLength; let start = rowIndex; while (start > firstDataRow) { const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string }; if (!prevItem || prevItem.album !== item.album) break; start--; } let end = rowIndex; while (end + 1 < maxRow) { const nextItem = getRowItem?.(end + 1) as null | undefined | { album?: string }; if (!nextItem || nextItem.album !== item.album) break; end++; } return end - start + 1; } export const ALBUM_GROUP_STACK_GAP = 8; export const ALBUM_GROUP_CELL_PADDING = 8; export function getAlbumGroupSpanHeight( groupRowCount: number, baseHeight: number, albumGroupImageSize: number, contentHeight = 0, options?: { isVertical?: boolean }, ): number { const rowSpanHeight = groupRowCount * baseHeight; const isVertical = options?.isVertical ?? false; const paddingY = ALBUM_GROUP_CELL_PADDING * 2; if (isVertical) { const imageSize = albumGroupImageSize > 0 ? albumGroupImageSize : 96; return Math.max( rowSpanHeight, imageSize + ALBUM_GROUP_STACK_GAP + contentHeight + paddingY, ); } const imageSpanHeight = albumGroupImageSize > 0 ? Math.max(albumGroupImageSize + paddingY, rowSpanHeight) : rowSpanHeight; return Math.max(imageSpanHeight, contentHeight > 0 ? contentHeight + paddingY : 0); } export function getAlbumGroupStartRowIndex( rowIndex: number, getRowItem: ((index: number) => unknown) | undefined, enableHeader: boolean | undefined, ): number { const item = getRowItem?.(rowIndex) as null | undefined | { album?: string }; if (!item?.album) return rowIndex; const firstDataRow = enableHeader ? 1 : 0; let start = rowIndex; while (start > firstDataRow) { const prevItem = getRowItem?.(start - 1) as null | undefined | { album?: string }; if (!prevItem || prevItem.album !== item.album) break; start--; } return start; } export function isAlbumGroupingActive(columns: { id: string; isEnabled?: boolean }[]): boolean { return columns.some((col) => col.id === TableColumn.ALBUM_GROUP && col.isEnabled); } export function isLastInAlbumGroup( rowIndex: number, getRowItem: ((index: number) => unknown) | undefined, enableHeader: boolean | undefined, dataLength: number, ): boolean { const item = getRowItem?.(rowIndex) as null | undefined | { album?: string }; if (!item?.album) return true; const nextRowIndex = rowIndex + 1; const maxRow = enableHeader ? dataLength + 1 : dataLength; if (nextRowIndex >= maxRow) return true; const nextItem = getRowItem?.(nextRowIndex) as null | undefined | { album?: string }; return !nextItem || nextItem.album !== item.album; } function baseRowHeightForSize(size: ItemTableListColumn['size']): number { if (size === 'compact') return TableItemSize.COMPACT; if (size === 'large') return TableItemSize.LARGE; return TableItemSize.DEFAULT; } // Wraps a clamped cell with the spacer that fills the reserved (grown) height // below it. The spacer carries the group's bottom/right borders so they align // across all columns. function ClampedCell({ cell, clampHeight, outerStyle, showHorizontalBorder, showVerticalBorder, }: { cell: ReactElement; clampHeight: null | number; outerStyle?: CSSProperties; showHorizontalBorder: boolean; showVerticalBorder: boolean; }): ReactElement { const grownHeight = typeof outerStyle?.height === 'number' ? outerStyle.height : 0; const spacerHeight = clampHeight !== null ? grownHeight - clampHeight : 0; if (clampHeight === null || spacerHeight <= 0) return cell; return (
{cell}
); } // When an enlarged album image extends past the album group's combined row // height, the last row of the group is grown (in getRowHeight) to reserve the // leftover space. This returns the standard (un-grown) height to clamp that // row's non-album cells to, so the track content + hover/selection stay at // standard height and the reserved space below is left empty (uniform // background) for the overflowing album image. function getAlbumGroupClampHeight(props: ItemTableListInnerColumn): null | number { if (props.type === TableColumn.ALBUM_GROUP) return null; if (!isAlbumGroupingActive(props.columns)) return null; const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; if (!isDataRow) return null; const item = props.getRowItem?.(props.rowIndex) as null | undefined | { album?: string }; if (!item?.album) return null; if ( !isLastInAlbumGroup(props.rowIndex, props.getRowItem, props.enableHeader, props.data.length) ) { return null; } const albumImageSize = props.albumGroupImageSize ?? 0; const isVertical = props.albumGroupVerticalLayout ?? false; const baseHeight = baseRowHeightForSize(props.size); const groupRowCount = getAlbumGroupRowCount( props.rowIndex, props.getRowItem, props.enableHeader, props.data.length, ); const groupStartRowIndex = getAlbumGroupStartRowIndex( props.rowIndex, props.getRowItem, props.enableHeader, ); const groupStartItem = props.getRowItem?.(groupStartRowIndex); const groupHeightKey = getAlbumGroupHeightKey(groupStartItem, groupRowCount); const measuredContentHeight = groupHeightKey ? props.albumGroupContentHeights?.get(groupHeightKey) : undefined; // Prefer measured info height once available. The controls row keeps a // min-height placeholder while the album query loads, so early measures // already reserve favorites/ratings space. const contentHeight = measuredContentHeight ?? props.estimatedAlbumGroupContentHeight ?? 0; const totalGroupHeight = getAlbumGroupSpanHeight( groupRowCount, baseHeight, albumImageSize, contentHeight, { isVertical }, ); // Only clamp when the row was actually grown to fit the image or wrapped text. if (totalGroupHeight <= groupRowCount * baseHeight) return null; return baseHeight; } function showHorizontalBorderFor(props: ItemTableListInnerColumn, isLastRow: boolean): boolean { if (!props.enableHorizontalBorders || !props.enableHeader || props.rowIndex <= 0) { return false; } // Album group uses group top/bottom edges only (no mid-group lines that would // cut through artwork). Other columns keep per-row borders. if (props.type === TableColumn.ALBUM_GROUP) { return isLastInAlbumGroup( props.rowIndex, props.getRowItem, !!props.enableHeader, props.data.length, ); } return props.rowIndex === 1 || !isLastRow; } export const TableColumnTextContainer = ( props: ItemTableListColumn & { children: React.ReactNode; className?: string; containerClassName?: string; controls: ItemControls; dragRef?: null | React.Ref; isDraggedOver?: 'bottom' | 'top' | null; isDragging?: boolean; type: TableColumn; }, ) => { const containerRef = useRef(null); const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; const item = isDataRow ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) : null; const itemRowId = item && typeof item === 'object' && 'id' in item ? props.internalState.extractRowId(item) : undefined; const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined); const clampHeight = getAlbumGroupClampHeight(props); const isDragging = props.isDragging ?? false; const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); const isLastColumn = props.columnIndex === props.columns.length - 1; const isLastRow = isDataRow && (props.enableHeader ? props.rowIndex === props.data.length : props.rowIndex === props.data.length - 1); // Apply dragged over state to all cells in the row so border can span entire row useEffect(() => { if (!isDataRow || !containerRef.current) return; const rowKey = `${props.tableId}-${props.rowIndex}`; const edge = props.isDraggedOver === 'top' || props.isDraggedOver === 'bottom' ? props.isDraggedOver : null; containerRef.current.dispatchEvent( new CustomEvent('itl:row-drag-over', { bubbles: true, detail: { edge, rowKey }, }), ); }, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]); const handleClick = useDoubleClick({ onDoubleClick: (event: React.MouseEvent) => { if (isDataRow && item) { const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onDoubleClick?.({ event, index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }, onSingleClick: (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) { const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onClick?.({ event, index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }, }); const handleContextMenu = (event: React.MouseEvent) => { if (isDataRow && item) { event.preventDefault(); const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onMore?.({ event, index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }; const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow); const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn && props.type !== TableColumn.ALBUM_GROUP; const cell = (
{props.children}
); return ( ); }; export const TableColumnContainer = ( props: ItemTableListColumn & { children: React.ReactNode; className?: string; containerStyle?: CSSProperties; controls: ItemControls; dragRef?: null | React.Ref; isDraggedOver?: 'bottom' | 'top' | null; isDragging?: boolean; type: TableColumn; }, ) => { const containerRef = useRef(null); const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; const item = isDataRow ? (props.getRowItem?.(props.rowIndex) ?? props.data[props.rowIndex]) : null; const itemRowId = item && typeof item === 'object' && 'id' in item ? props.internalState.extractRowId(item) : undefined; const isSelected = useItemSelectionState(props.internalState, itemRowId || undefined); const clampHeight = getAlbumGroupClampHeight(props); const isDragging = props.isDragging ?? false; const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); const isLastColumn = props.columnIndex === props.columns.length - 1; const isLastRow = isDataRow && (props.enableHeader ? props.rowIndex === props.data.length : props.rowIndex === props.data.length - 1); // Apply dragged over state to all cells in the row so border can span entire row useEffect(() => { if (!isDataRow || !containerRef.current) return; const rowKey = `${props.tableId}-${props.rowIndex}`; const edge = props.isDraggedOver === 'top' || props.isDraggedOver === 'bottom' ? props.isDraggedOver : null; containerRef.current.dispatchEvent( new CustomEvent('itl:row-drag-over', { bubbles: true, detail: { edge, rowKey }, }), ); }, [isDataRow, props.isDraggedOver, props.rowIndex, props.tableId]); const handleClick = useDoubleClick({ onDoubleClick: (event: React.MouseEvent) => { if (isDataRow && item) { const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onDoubleClick?.({ event, index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }, onSingleClick: (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) { const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onClick?.({ event, index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }, }); const handleContextMenu = (event: React.MouseEvent) => { if (isDataRow && item) { event.preventDefault(); const rowId = props.internalState.extractRowId(item); const index = rowId ? props.internalState.findItemIndex(rowId) : -1; props.controls.onMore?.({ event, index, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }; const showHorizontalBorder = showHorizontalBorderFor(props, isLastRow); const showVerticalBorder = !!props.enableVerticalBorders && !isLastColumn && props.type !== TableColumn.ALBUM_GROUP; const cell = (
0, [styles.paddingLg]: props.cellPadding === 'lg', [styles.paddingMd]: props.cellPadding === 'md', [styles.paddingSm]: props.cellPadding === 'sm', [styles.paddingXl]: props.cellPadding === 'xl', [styles.paddingXs]: props.cellPadding === 'xs', [styles.right]: props.columns[props.columnIndex].align === 'end', [styles.rowHoverHighlightEnabled]: isDataRow && props.enableRowHoverHighlight && props.type !== TableColumn.ALBUM_GROUP, [styles.rowSelected]: isDataRow && isSelected && props.type !== TableColumn.ALBUM_GROUP, // When clamped, the bottom border is drawn on the spacer below instead. [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null, [styles.withVerticalBorder]: showVerticalBorder, })} data-exclude-row-drag-border={props.type === TableColumn.ALBUM_GROUP ? true : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleClick} onContextMenu={handleContextMenu} ref={mergedRef} style={ clampHeight !== null ? { ...props.containerStyle, height: clampHeight } : { ...props.containerStyle, ...props.style } } > {props.children}
); return ( ); }; interface ColumnResizeHandleProps { columnId: TableColumn; columnIndex: number; disabled?: boolean; initialWidth: number; onResize: (columnId: TableColumn, width: number) => void; side: 'left' | 'right'; } const ColumnResizeHandle = ({ columnId, columnIndex, disabled = false, 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); const columnResizeLive = useItemTableListColumnResizeLive(); const onResizeRef = useRef(onResize); const columnResizeLiveRef = useRef(columnResizeLive); useEffect(() => { onResizeRef.current = onResize; }, [onResize]); useEffect(() => { columnResizeLiveRef.current = columnResizeLive; }, [columnResizeLive]); // 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; columnResizeLiveRef.current?.scheduleColumnResizePreview(columnIndex, newWidth); }; const handleMouseUp = () => { setIsDragging(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); onResizeRef.current(columnId, finalWidthRef.current); columnResizeLiveRef.current?.clearColumnResizePreview(); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; columnResizeLiveRef.current?.clearColumnResizePreview(); }; }, [isDragging, columnId, columnIndex]); const handleMouseDown = (event: React.MouseEvent) => { if (disabled) { event.preventDefault(); event.stopPropagation(); return; } 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; containerClassName?: string; controls: ItemControls; 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 }); }; const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isDraggedOver, setIsDraggedOver] = useState(null); useEffect(() => { if ( !containerRef.current || !props.enableColumnReorder || props.type === TableColumn.LAYOUT_FILL ) { return; } const handleReorder = ( columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null, ) => { props.controls.onColumnReordered?.({ columnIdFrom, columnIdTo, edge }); }; return combine( draggable({ element: containerRef.current, getInitialData: () => { const data = dndUtils.generateDragData( { id: [props.type], operation: [DragOperation.REORDER], type: DragTarget.TABLE_COLUMN, }, { tableId: props.tableId }, ); return data; }, onDragStart: () => { setIsDragging(true); }, onDrop: () => { setIsDragging(false); }, onGenerateDragPreview: (data) => { disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage }); }, }), dropTargetForElements({ canDrop: (args) => { const data = args.source.data as unknown as DragData; const sourceTableId = (data.metadata as { tableId?: string })?.tableId; const isSelf = (args.source.data.id as string[])[0] === props.type; const isSameTable = sourceTableId === props.tableId; return ( dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && !isSelf && isSameTable ); }, element: containerRef.current, getData: ({ element, input }) => { const data = dndUtils.generateDragData( { id: [props.type], operation: [DragOperation.REORDER], type: DragTarget.TABLE_COLUMN, }, { tableId: props.tableId }, ); return attachClosestEdge(data, { allowedEdges: ['left', 'right'], element, input, }); }, onDrag: (args) => { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); setIsDraggedOver(closestEdgeOfTarget); }, onDragLeave: () => { setIsDraggedOver(null); }, onDrop: (args) => { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); const from = args.source.data.id as string[]; const to = args.self.data.id as string[]; handleReorder( from[0] as TableColumn, to[0] as TableColumn, closestEdgeOfTarget, ); setIsDraggedOver(null); }, }), ); }, [props.type, props.enableColumnReorder, props.controls, props.tableId]); return ( {columnLabelMap[props.type]} {props.enableColumnResize && ( )} ); }; export const columnLabelMap: Record = { [TableColumn.ACTIONS]: ( ), [TableColumn.ALBUM]: i18n.t('table.column.album', { postProcess: 'upperCase' }) as string, [TableColumn.ALBUM_ARTIST]: i18n.t('table.column.albumArtist', { postProcess: 'upperCase', }) as string, [TableColumn.ALBUM_COUNT]: i18n.t('table.column.albumCount', { postProcess: 'upperCase', }) as string, [TableColumn.ALBUM_GROUP]: '', [TableColumn.ARTIST]: i18n.t('table.column.artist', { postProcess: 'upperCase' }) as string, [TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', { postProcess: 'upperCase', }) as string, [TableColumn.BIT_DEPTH]: i18n.t('table.column.bitDepth', { postProcess: 'upperCase', }) as string, [TableColumn.BIT_RATE]: i18n.t('table.column.bitrate', { postProcess: 'upperCase' }) as string, [TableColumn.BPM]: i18n.t('table.column.bpm', { postProcess: 'upperCase' }) as string, [TableColumn.CHANNELS]: i18n.t('table.column.channels', { postProcess: 'upperCase' }) as string, [TableColumn.CODEC]: i18n.t('table.column.codec', { postProcess: 'upperCase' }) as string, [TableColumn.COMMENT]: i18n.t('table.column.comment', { postProcess: 'upperCase' }) as string, [TableColumn.COMPOSER]: i18n.t('table.config.label.composer', { postProcess: 'upperCase', }) as string, [TableColumn.DATE]: i18n.t('table.column.date', { postProcess: 'upperCase', }) as string, [TableColumn.DATE_ADDED]: i18n.t('table.column.dateAdded', { postProcess: 'upperCase', }) as string, [TableColumn.DISC_NUMBER]: ( ), [TableColumn.DURATION]: ( ), [TableColumn.GENRE]: i18n.t('table.column.genre', { postProcess: 'upperCase' }) as string, [TableColumn.GENRE_BADGE]: i18n.t('table.column.genre', { postProcess: 'upperCase', }) as string, [TableColumn.ID]: 'ID', [TableColumn.IMAGE]: '', [TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', { postProcess: 'upperCase', }) as string, [TableColumn.LAYOUT_FILL]: '', [TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string, [TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string, [TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', { postProcess: 'upperCase', }) as string, [TableColumn.PLAYLIST_REORDER]: ( ), [TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', { postProcess: 'upperCase', }) as string, [TableColumn.RELEASE_YEAR]: i18n.t('table.column.releaseYear', { postProcess: 'upperCase', }) as string, [TableColumn.ROW_INDEX]: ( ), [TableColumn.SAMPLE_RATE]: i18n.t('table.column.sampleRate', { postProcess: 'upperCase', }) as string, [TableColumn.SIZE]: i18n.t('table.column.size', { postProcess: 'upperCase' }) as string, [TableColumn.SKIP]: '', [TableColumn.SONG_COUNT]: i18n.t('table.column.songCount', { postProcess: 'upperCase', }) as string, [TableColumn.TITLE]: i18n.t('table.column.title', { postProcess: 'upperCase' }) as string, [TableColumn.TITLE_ARTIST]: i18n.t('table.column.title', { postProcess: 'upperCase', }) as string, [TableColumn.TITLE_COMBINED]: i18n.t('table.column.title', { postProcess: 'upperCase', }) as string, [TableColumn.TRACK_NUMBER]: ( ), [TableColumn.USER_FAVORITE]: ( ), [TableColumn.USER_RATING]: ( ), [TableColumn.YEAR]: i18n.t('table.column.year', { postProcess: 'upperCase' }) as string, }; export const ColumnNullFallback = (props: ItemTableListInnerColumn) => { return  ; }; export const ColumnSkeletonVariable = (props: ItemTableListInnerColumn) => { return ( ); }; export const ColumnSkeletonFixed = (props: ItemTableListInnerColumn) => { return ( ); };