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 7adf6a717..946667215 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -192,9 +192,10 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs onColumnReordered?.(columnIdFrom, columnIdTo, edge); }, - onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => { - onColumnResized?.(columnId, width); - }, + onColumnResized: onColumnResized + ? ({ columnId, width }: { columnId: TableColumn; width: number }) => + onColumnResized(columnId, width) + : undefined, onDoubleClick: ({ internalState, item, itemType, meta }: DefaultItemControlProps) => { if (!item || !internalState) { diff --git a/src/renderer/components/item-list/item-detail-list/item-detail.module.css b/src/renderer/components/item-list/item-detail-list/item-detail.module.css index ddf8331c8..7dbe48299 100644 --- a/src/renderer/components/item-list/item-detail-list/item-detail.module.css +++ b/src/renderer/components/item-list/item-detail-list/item-detail.module.css @@ -78,14 +78,13 @@ } .track-header-cell { + position: relative; display: flex; align-items: center; min-width: 0; padding-right: var(--theme-spacing-sm); padding-left: var(--theme-spacing-sm); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: visible; } .track-header-cell-no-h-padding { @@ -97,6 +96,89 @@ border-right: 1px solid var(--theme-colors-border); } +.track-header-cell-dragging { + cursor: grabbing; + opacity: 0.5; +} + +.track-header-cell-dragged-over-left::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 10; + width: 3px; + content: ''; + background-color: var(--theme-colors-primary); +} + +.track-header-cell-dragged-over-right::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 10; + width: 3px; + content: ''; + background-color: var(--theme-colors-primary); +} + +.track-header-cell:hover .resize-handle { + opacity: 1; +} + +.track-header-cell:hover .resize-handle::before { + background-color: var(--theme-colors-border); +} + +.resize-handle { + position: absolute; + top: 0; + bottom: 0; + z-index: 10; + width: 2px; + margin-right: -4px; + cursor: col-resize; + background: var(--theme-colors-border); + opacity: 0; + transition: opacity 0.3s ease; +} + +/* .resize-handle::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 2px; + content: ''; + background-color: transparent; + transition: background-color 0.15s ease; +} */ + +.resize-handle-left { + left: 0; + margin-right: 0; + margin-left: -4px; +} + +.resize-handle-left::before { + right: auto; + left: 0; +} + +.resize-handle-right { + right: 0; + margin-right: 0; +} + +.resize-handle-dragging { + opacity: 1; +} + +.resize-handle:hover { + opacity: 1; +} + .row { display: grid; grid-template-columns: 240px 1fr; diff --git a/src/renderer/components/item-list/item-detail-list/item-detail.tsx b/src/renderer/components/item-list/item-detail-list/item-detail.tsx index 40e10f1c8..9377dbabb 100644 --- a/src/renderer/components/item-list/item-detail-list/item-detail.tsx +++ b/src/renderer/components/item-list/item-detail-list/item-detail.tsx @@ -1,3 +1,14 @@ +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 { useQuery, useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; import throttle from 'lodash/throttle'; @@ -58,6 +69,7 @@ import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/expli import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { useDoubleClick } from '/@/shared/hooks/use-double-click'; import { Album, LibraryItem, Song, SongListSort, SortOrder } from '/@/shared/types/domain-types'; +import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { ItemListKey, Play, TableColumn } from '/@/shared/types/types'; const DEFAULT_ROW_HEIGHT = 300; @@ -72,10 +84,17 @@ interface ItemDetailListProps { internalState?: ItemListStateActions; itemCount?: number; items?: unknown[]; + onColumnReordered?: ( + columnIdFrom: TableColumn, + columnIdTo: TableColumn, + edge: 'bottom' | 'left' | 'right' | 'top' | null, + ) => void; + onColumnResized?: (columnId: TableColumn, width: number) => void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise | void; onScrollEnd?: (rowIndex: number) => void; rowHeight?: number; scrollOffset?: number; + tableId?: string; } interface RowData { @@ -731,10 +750,259 @@ const RowComponent = memo((props: RowComponentProps): ReactElement => { RowComponent.displayName = 'ItemDetailRow'; +interface DetailListHeaderCellProps { + columnId: TableColumn; + columnWidthPercents: number[]; + enableColumnResize?: boolean; + enableVerticalBorders: boolean; + isLastColumn: boolean; + onColumnReordered?: (args: { + columnIdFrom: TableColumn; + columnIdTo: TableColumn; + edge: Edge | null; + }) => void; + onColumnResized?: (columnId: TableColumn, width: number) => void; + tableId: string; + trackColumns: ItemTableListColumnConfig[]; +} + +const DetailListHeaderCell = memo( + ({ + columnId, + columnWidthPercents, + enableColumnResize, + enableVerticalBorders, + isLastColumn, + onColumnReordered, + onColumnResized, + tableId, + trackColumns, + }: DetailListHeaderCellProps) => { + const containerRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [isDraggedOver, setIsDraggedOver] = useState(null); + const colIndex = trackColumns.findIndex((c) => c.id === columnId); + const col = colIndex >= 0 ? trackColumns[colIndex] : null; + const percent = col ? (columnWidthPercents[colIndex] ?? 0) : 0; + const { fixedWidth, isFixedColumn } = getTrackColumnFixed(columnId); + const currentWidth = col?.width ?? (fixedWidth || 100); + const showResizeHandle = + enableColumnResize && !isFixedColumn && !col?.autoSize && onColumnResized; + + useEffect(() => { + if (!containerRef.current || !onColumnReordered) { + return; + } + + const handleReorder = ( + columnIdFrom: TableColumn, + columnIdTo: TableColumn, + edge: Edge | null, + ) => { + onColumnReordered({ columnIdFrom, columnIdTo, edge }); + }; + + return combine( + draggable({ + element: containerRef.current, + getInitialData: () => { + const data = dndUtils.generateDragData( + { + id: [columnId], + operation: [DragOperation.REORDER], + type: DragTarget.TABLE_COLUMN, + }, + { 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] === columnId; + const isSameTable = sourceTableId === tableId; + return ( + dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && + !isSelf && + isSameTable + ); + }, + element: containerRef.current, + getData: ({ element, input }) => { + const data = dndUtils.generateDragData( + { + id: [columnId], + operation: [DragOperation.REORDER], + type: DragTarget.TABLE_COLUMN, + }, + { tableId }, + ); + return attachClosestEdge(data, { + allowedEdges: ['left', 'right'], + element, + input, + }); + }, + onDrag: (args) => { + const closestEdgeOfTarget = extractClosestEdge(args.self.data); + setIsDraggedOver(closestEdgeOfTarget); + }, + onDragLeave: () => setIsDraggedOver(null), + onDrop: (args) => { + const closestEdgeOfTarget = 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); + }, + }), + ); + }, [columnId, onColumnReordered, tableId]); + + const style: React.CSSProperties = { + flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, + justifyContent: colTypeToJustifyContentMap[col?.align ?? 'start'], + minWidth: isFixedColumn ? fixedWidth : 0, + textAlign: colTypeToAlignMap[col?.align ?? 'start'] as 'center' | 'left' | 'right', + }; + + const handleResize = useCallback( + (id: TableColumn, width: number) => { + onColumnResized?.(id, width); + }, + [onColumnResized], + ); + + return ( +
+ {columnLabelMap[columnId] ?? ''} + {showResizeHandle && ( + + )} +
+ ); + }, +); + +DetailListHeaderCell.displayName = 'DetailListHeaderCell'; + +interface DetailListColumnResizeHandleProps { + columnId: TableColumn; + initialWidth: number; + onResize: (columnId: TableColumn, width: number) => void; + side: 'left' | 'right'; +} + +const DetailListColumnResizeHandle = ({ + columnId, + initialWidth, + onResize, + side, +}: DetailListColumnResizeHandleProps) => { + const [isDragging, setIsDragging] = useState(false); + const handleRef = useRef(null); + const startWidthRef = useRef(initialWidth); + const startXRef = useRef(0); + const finalWidthRef = useRef(initialWidth); + + useEffect(() => { + if (!isDragging) { + startWidthRef.current = initialWidth; + } + }, [initialWidth, isDragging]); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (event: MouseEvent) => { + const deltaX = event.clientX - startXRef.current; + const newWidth = Math.min(Math.max(10, startWidthRef.current + deltaX), 1000); + finalWidthRef.current = newWidth; + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + onResize(columnId, finalWidthRef.current); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, columnId, onResize]); + + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + startWidthRef.current = initialWidth; + startXRef.current = event.clientX; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; + + return ( +
+ ); +}; + interface DetailListHeaderProps { columnWidthPercents: number[]; + enableColumnReorder?: boolean; + enableColumnResize?: boolean; enableVerticalBorders: boolean; headerLeftRef: React.RefObject; + onColumnReordered?: (args: { + columnIdFrom: TableColumn; + columnIdTo: TableColumn; + edge: Edge | null; + }) => void; + onColumnResized?: (columnId: TableColumn, width: number) => void; + tableId: string; trackColumns: ItemTableListColumnConfig[]; trackTableSize: 'compact' | 'default' | 'large'; } @@ -754,8 +1022,13 @@ const colTypeToJustifyContentMap = { const DetailListHeader = memo( ({ columnWidthPercents, + enableColumnReorder, + enableColumnResize, enableVerticalBorders, headerLeftRef, + onColumnReordered, + onColumnResized, + tableId, trackColumns, trackTableSize, }: DetailListHeaderProps) => { @@ -778,10 +1051,30 @@ const DetailListHeader = memo( role="row" > {trackColumns.map((col, colIndex) => { - const percent = columnWidthPercents[colIndex] ?? 0; - const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id); const isLastColumn = colIndex === trackColumns.length - 1; + if ( + (enableColumnResize && onColumnResized) || + (enableColumnReorder && onColumnReordered) + ) { + return ( + + ); + } + + const percent = columnWidthPercents[colIndex] ?? 0; + const { fixedWidth, isFixedColumn } = getTrackColumnFixed(col.id); const style: React.CSSProperties = { flex: isFixedColumn ? `0 0 ${fixedWidth}px` : `${percent} 1 0`, justifyContent: colTypeToJustifyContentMap[col.align], @@ -804,7 +1097,9 @@ const DetailListHeader = memo( role="columnheader" style={style} > - {columnLabelMap[col.id] ?? ''} + + {columnLabelMap[col.id] ?? ''} +
); })} @@ -819,6 +1114,8 @@ DetailListHeader.displayName = 'DetailListHeader'; const SCROLL_END_DEBOUNCE_MS = 150; +const DEFAULT_DETAIL_TABLE_ID = 'album-detail'; + export const ItemDetailList = ({ currentPage, data, @@ -826,15 +1123,21 @@ export const ItemDetailList = ({ getItem, itemCount: externalItemCount, items, + onColumnReordered, + onColumnResized, onRangeChanged, onScrollEnd, + tableId = DEFAULT_DETAIL_TABLE_ID, }: ItemDetailListProps) => { const containerRef = useRef(null); const listRef = useListRef(null); const lastVisibleStartIndexRef = useRef(0); const queryClient = useQueryClient(); - const controls = useDefaultItemListControls(); + const controls = useDefaultItemListControls({ + onColumnReordered, + onColumnResized, + }); const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite; @@ -1053,8 +1356,17 @@ export const ItemDetailList = ({ {enableHeader && ( controls.onColumnResized?.({ columnId, width }) + : undefined + } + tableId={tableId} trackColumns={trackColumns} trackTableSize={trackTableSize} /> diff --git a/src/renderer/features/albums/components/album-list-infinite-detail.tsx b/src/renderer/features/albums/components/album-list-infinite-detail.tsx index 599814886..f04dd1eac 100644 --- a/src/renderer/features/albums/components/album-list-infinite-detail.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-detail.tsx @@ -2,6 +2,8 @@ import { UseSuspenseQueryOptions } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail'; import { ItemListComponentProps } from '/@/renderer/components/item-list/types'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; @@ -33,6 +35,18 @@ export const AlbumListInfiniteDetail = ({ const listQueryFn = api.controller.getAlbumList; + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + console.log('handleColumnResized', handleColumnResized); + const { getItem, itemCount, loadedItems, onRangeChanged } = useItemListInfiniteLoader({ eventKey: ItemListKey.ALBUM, itemsPerPage, @@ -49,6 +63,8 @@ export const AlbumListInfiniteDetail = ({ enableHeader={enableHeader} getItem={getItem} itemCount={itemCount} + onColumnReordered={handleColumnReordered} + onColumnResized={handleColumnResized} onRangeChanged={onRangeChanged} /> ); diff --git a/src/renderer/features/albums/components/album-list-paginated-detail.tsx b/src/renderer/features/albums/components/album-list-paginated-detail.tsx index 694eff28a..13c1d9613 100644 --- a/src/renderer/features/albums/components/album-list-paginated-detail.tsx +++ b/src/renderer/features/albums/components/album-list-paginated-detail.tsx @@ -2,6 +2,8 @@ import { UseSuspenseQueryOptions } from '@tanstack/react-query'; import { api } from '/@/renderer/api'; import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; import { ItemDetailList } from '/@/renderer/components/item-list/item-detail-list/item-detail'; import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; @@ -35,6 +37,16 @@ export const AlbumListPaginatedDetail = ({ const listQueryFn = api.controller.getAlbumList; + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.ALBUM, + tableKey: 'detail', + }); + const { currentPage, onChange } = useItemListPagination(); const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ @@ -60,6 +72,8 @@ export const AlbumListPaginatedDetail = ({ currentPage={currentPage} enableHeader={enableHeader} items={data || []} + onColumnReordered={handleColumnReordered} + onColumnResized={handleColumnResized} /> );