mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 05:12:57 +02:00
64dc3c1bc2
* added missing date/year fields for other song references * added year table column & sorting * added navidrome track date view for grid, detail & table. * update table column config * Displays date & release date using formatPartialIsoDateUTC, and displays respective year field if no date present added releaseYear player filter Made fullscreen player show formatted date fixed minor year/release year value mismatches added subsonic date/year field to enable the fields in folder view for navidrome using subsonic year field --------- Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
1382 lines
56 KiB
TypeScript
1382 lines
56 KiB
TypeScript
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<TableItemProps> {
|
|
columnType?: TableColumn;
|
|
}
|
|
|
|
export interface ItemTableListInnerColumn extends ItemTableListColumn {
|
|
controls: ItemControls;
|
|
dragRef?: null | React.Ref<HTMLDivElement>;
|
|
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 <TableColumnHeaderContainer {...props} controls={controls} type={type} />;
|
|
}
|
|
|
|
// 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 <div style={{ ...props.style }} />;
|
|
}
|
|
// 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 (
|
|
<div
|
|
style={{
|
|
...props.style,
|
|
marginLeft: 0,
|
|
marginRight: 0,
|
|
}}
|
|
>
|
|
{groupHeader}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// For main grid, use negative margin to extend left
|
|
return (
|
|
<div
|
|
style={{
|
|
...props.style,
|
|
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
|
|
}}
|
|
>
|
|
{groupHeader}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (type === TableColumn.LAYOUT_FILL) {
|
|
return (
|
|
<TableColumnContainer {...props} {...dragProps} controls={controls} type={type}>
|
|
{null}
|
|
</TableColumnContainer>
|
|
);
|
|
}
|
|
|
|
if (itemType !== LibraryItem.FOLDER) {
|
|
switch (type) {
|
|
case TableColumn.ACTIONS:
|
|
case TableColumn.SKIP:
|
|
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.ALBUM:
|
|
return <AlbumColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.ALBUM_ARTIST:
|
|
return (
|
|
<AlbumArtistsColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.ALBUM_COUNT:
|
|
case TableColumn.PLAY_COUNT:
|
|
case TableColumn.SONG_COUNT:
|
|
return <CountColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.ALBUM_GROUP:
|
|
return (
|
|
<AlbumGroupColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.ARTIST:
|
|
return <ArtistsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.BIOGRAPHY:
|
|
case TableColumn.COMMENT:
|
|
return <TextColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.BIT_DEPTH:
|
|
case TableColumn.BIT_RATE:
|
|
case TableColumn.BPM:
|
|
case TableColumn.CHANNELS:
|
|
case TableColumn.DISC_NUMBER:
|
|
case TableColumn.SAMPLE_RATE:
|
|
return <NumericColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.COMPOSER:
|
|
return <ComposerColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.DATE:
|
|
return (
|
|
<TrackDateColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.DATE_ADDED:
|
|
return <DateColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.DURATION:
|
|
return <DurationColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.GENRE:
|
|
return <GenreColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.GENRE_BADGE:
|
|
return (
|
|
<GenreBadgeColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.IMAGE:
|
|
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.LAST_PLAYED:
|
|
return (
|
|
<RelativeDateColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.PATH:
|
|
return <PathColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.PLAYLIST_REORDER:
|
|
return <PlaylistReorderColumn {...props} controls={controls} type={type} />;
|
|
|
|
case TableColumn.RELEASE_DATE:
|
|
return (
|
|
<AbsoluteDateColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.RELEASE_YEAR:
|
|
return (
|
|
<ReleaseYearColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.ROW_INDEX:
|
|
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.SIZE:
|
|
return <SizeColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.TITLE:
|
|
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.TITLE_ARTIST:
|
|
return (
|
|
<TitleArtistColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.TITLE_COMBINED:
|
|
return (
|
|
<TitleCombinedColumn
|
|
{...props}
|
|
{...dragProps}
|
|
controls={controls}
|
|
type={type}
|
|
/>
|
|
);
|
|
|
|
case TableColumn.TRACK_NUMBER:
|
|
return (
|
|
<TrackNumberColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
case TableColumn.USER_FAVORITE:
|
|
return <FavoriteColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.USER_RATING:
|
|
return <RatingColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.YEAR:
|
|
return <YearColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
default:
|
|
return <DefaultColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
}
|
|
}
|
|
|
|
switch (type) {
|
|
case TableColumn.ACTIONS:
|
|
return <ActionsColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.IMAGE:
|
|
return <ImageColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.ROW_INDEX:
|
|
return <RowIndexColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.TITLE:
|
|
return <TitleColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.TITLE_ARTIST:
|
|
return <TitleArtistColumn {...props} {...dragProps} controls={controls} type={type} />;
|
|
|
|
case TableColumn.TITLE_COMBINED:
|
|
return (
|
|
<TitleCombinedColumn {...props} {...dragProps} controls={controls} type={type} />
|
|
);
|
|
|
|
default:
|
|
return <ColumnNullFallback {...props} {...dragProps} controls={controls} type={type} />;
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div style={outerStyle}>
|
|
{cell}
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
borderBottom: showHorizontalBorder
|
|
? '1px solid var(--theme-colors-border)'
|
|
: undefined,
|
|
borderRight: showVerticalBorder
|
|
? '1px solid var(--theme-colors-border)'
|
|
: undefined,
|
|
height: spacerHeight,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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<HTMLDivElement>;
|
|
isDraggedOver?: 'bottom' | 'top' | null;
|
|
isDragging?: boolean;
|
|
type: TableColumn;
|
|
},
|
|
) => {
|
|
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
// 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<HTMLDivElement>) => {
|
|
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 = (
|
|
<div
|
|
className={clsx(styles.container, props.containerClassName, {
|
|
[styles.alternateRowEven]:
|
|
props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 0,
|
|
[styles.alternateRowOdd]:
|
|
props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 1,
|
|
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
|
[styles.compact]: props.size === 'compact',
|
|
[styles.dataRow]: isDataRow,
|
|
[styles.dragging]: isDataRow && isDragging,
|
|
[styles.large]: props.size === 'large',
|
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
|
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
|
[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,
|
|
[styles.rowSelected]: isDataRow && isSelected,
|
|
// When clamped, the bottom border is drawn on the spacer below
|
|
// instead.
|
|
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
|
|
[styles.withVerticalBorder]: showVerticalBorder,
|
|
})}
|
|
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
|
|
onClick={handleClick}
|
|
onContextMenu={handleContextMenu}
|
|
ref={mergedRef}
|
|
style={clampHeight !== null ? { height: clampHeight } : props.style}
|
|
>
|
|
<Text
|
|
className={clsx(styles.content, props.className, {
|
|
[styles.compact]: props.size === 'compact',
|
|
[styles.large]: props.size === 'large',
|
|
})}
|
|
isMuted={!NonMutedColumns.includes(props.type)}
|
|
isNoSelect
|
|
>
|
|
{props.children}
|
|
</Text>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<ClampedCell
|
|
cell={cell}
|
|
clampHeight={clampHeight}
|
|
outerStyle={props.style}
|
|
showHorizontalBorder={showHorizontalBorder}
|
|
showVerticalBorder={showVerticalBorder}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const TableColumnContainer = (
|
|
props: ItemTableListColumn & {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
containerStyle?: CSSProperties;
|
|
controls: ItemControls;
|
|
dragRef?: null | React.Ref<HTMLDivElement>;
|
|
isDraggedOver?: 'bottom' | 'top' | null;
|
|
isDragging?: boolean;
|
|
type: TableColumn;
|
|
},
|
|
) => {
|
|
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
// 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<HTMLDivElement>) => {
|
|
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 = (
|
|
<div
|
|
className={clsx(styles.container, props.className, {
|
|
[styles.alternateRowEven]:
|
|
props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 0,
|
|
[styles.alternateRowOdd]:
|
|
props.enableAlternateRowColors && isDataRow && dataIndex % 2 === 1,
|
|
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
|
[styles.compact]: props.size === 'compact',
|
|
[styles.dataRow]: isDataRow,
|
|
[styles.dragging]: isDataRow && isDragging,
|
|
[styles.large]: props.size === 'large',
|
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
|
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
|
[styles.noVerticalPadding]:
|
|
props.type === TableColumn.ALBUM_GROUP && (props.albumGroupImageSize ?? 0) > 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}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<ClampedCell
|
|
cell={cell}
|
|
clampHeight={clampHeight}
|
|
outerStyle={props.style}
|
|
showHorizontalBorder={showHorizontalBorder}
|
|
showVerticalBorder={showVerticalBorder}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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<HTMLDivElement>(null);
|
|
const startWidthRef = useRef<number>(initialWidth);
|
|
const startXRef = useRef<number>(0);
|
|
const finalWidthRef = useRef<number>(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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
className={clsx(styles.resizeHandle, {
|
|
[styles.resizeHandleDisabled]: disabled,
|
|
[styles.resizeHandleDragging]: isDragging,
|
|
[styles.resizeHandleLeft]: side === 'left',
|
|
[styles.resizeHandleRight]: side === 'right',
|
|
})}
|
|
onMouseDown={handleMouseDown}
|
|
ref={handleRef}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isDraggedOver, setIsDraggedOver] = useState<Edge | null>(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 (
|
|
<Flex
|
|
className={clsx(styles.container, styles.headerContainer, props.containerClassName, {
|
|
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
|
|
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
|
|
[styles.headerDragging]: isDragging,
|
|
[styles.noHorizontalPadding]: isNoHorizontalPaddingColumn(props.type),
|
|
[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',
|
|
})}
|
|
ref={containerRef}
|
|
style={props.style}
|
|
>
|
|
<Text
|
|
className={clsx(styles.headerContent, props.className, {
|
|
[styles.center]: props.columns[props.columnIndex].align === 'center',
|
|
[styles.left]: props.columns[props.columnIndex].align === 'start',
|
|
[styles.right]: props.columns[props.columnIndex].align === 'end',
|
|
})}
|
|
isNoSelect
|
|
>
|
|
{columnLabelMap[props.type]}
|
|
</Text>
|
|
{props.enableColumnResize && (
|
|
<ColumnResizeHandle
|
|
columnId={props.type}
|
|
columnIndex={props.columnIndex}
|
|
disabled={!!columnConfig.autoSize}
|
|
initialWidth={currentWidth}
|
|
onResize={handleResize}
|
|
side="right"
|
|
/>
|
|
)}
|
|
</Flex>
|
|
);
|
|
};
|
|
|
|
export const columnLabelMap: Record<TableColumn, ReactNode | string> = {
|
|
[TableColumn.ACTIONS]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon fill="default" icon="ellipsisHorizontal" />
|
|
</Flex>
|
|
),
|
|
[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]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="disc" />
|
|
</Flex>
|
|
),
|
|
[TableColumn.DURATION]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="duration" />
|
|
</Flex>
|
|
),
|
|
[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]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="dragVertical" />
|
|
</Flex>
|
|
),
|
|
[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]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="hash" />
|
|
</Flex>
|
|
),
|
|
[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]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="itemSong" />
|
|
</Flex>
|
|
),
|
|
[TableColumn.USER_FAVORITE]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="favorite" />
|
|
</Flex>
|
|
),
|
|
[TableColumn.USER_RATING]: (
|
|
<Flex className={styles.headerIconWrapper}>
|
|
<Icon icon="star" />
|
|
</Flex>
|
|
),
|
|
[TableColumn.YEAR]: i18n.t('table.column.year', { postProcess: 'upperCase' }) as string,
|
|
};
|
|
|
|
export const ColumnNullFallback = (props: ItemTableListInnerColumn) => {
|
|
return <TableColumnTextContainer {...props}> </TableColumnTextContainer>;
|
|
};
|
|
|
|
export const ColumnSkeletonVariable = (props: ItemTableListInnerColumn) => {
|
|
return (
|
|
<TableColumnContainer {...props}>
|
|
<Skeleton height="1rem" width={`${props.rowIndex % 2 === 0 ? '80%' : '60%'}`} />
|
|
</TableColumnContainer>
|
|
);
|
|
};
|
|
|
|
export const ColumnSkeletonFixed = (props: ItemTableListInnerColumn) => {
|
|
return (
|
|
<TableColumnContainer {...props}>
|
|
<Skeleton height="1rem" width="80%" />
|
|
</TableColumnContainer>
|
|
);
|
|
};
|