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 99ae9105a..707e5e15a 100644 --- a/src/renderer/components/item-list/helpers/item-list-controls.ts +++ b/src/renderer/components/item-list/helpers/item-list-controls.ts @@ -9,6 +9,11 @@ import { LibraryItem, QueueSong } from '/@/shared/types/domain-types'; import { Play, TableColumn } from '/@/shared/types/types'; interface UseDefaultItemListControlsArgs { + onColumnReordered?: ( + columnIdFrom: TableColumn, + columnIdTo: TableColumn, + edge: 'bottom' | 'left' | 'right' | 'top' | null, + ) => void; onColumnResized?: (columnId: TableColumn, width: number) => void; } @@ -16,7 +21,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs const player = usePlayerContext(); const navigate = useNavigate(); - const { onColumnResized } = args || {}; + const { onColumnReordered, onColumnResized } = args || {}; const controls: ItemControls = useMemo(() => { return { @@ -153,6 +158,18 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs } }, + onColumnReordered: ({ + columnIdFrom, + columnIdTo, + edge, + }: { + columnIdFrom: TableColumn; + columnIdTo: TableColumn; + edge: 'bottom' | 'left' | 'right' | 'top' | null; + }) => { + onColumnReordered?.(columnIdFrom, columnIdTo, edge); + }, + onColumnResized: ({ columnId, width }: { columnId: TableColumn; width: number }) => { onColumnResized?.(columnId, width); }, @@ -249,7 +266,7 @@ export const useDefaultItemListControls = (args?: UseDefaultItemListControlsArgs player.setRating(item._serverId, [item.id], itemType, newRating); }, }; - }, [onColumnResized, navigate, player]); + }, [onColumnReordered, onColumnResized, navigate, player]); return controls; }; diff --git a/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts b/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts new file mode 100644 index 000000000..9d54560ae --- /dev/null +++ b/src/renderer/components/item-list/helpers/use-item-list-column-reorder.ts @@ -0,0 +1,96 @@ +import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; + +import { useCallback } from 'react'; + +import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store'; +import { ItemListKey, TableColumn } from '/@/shared/types/types'; + +interface UseItemListColumnReorderProps { + itemListKey: ItemListKey; +} + +export const useItemListColumnReorder = ({ itemListKey }: UseItemListColumnReorderProps) => { + const { setList } = useSettingsStoreActions(); + + const handleColumnReordered = useCallback( + (columnIdFrom: TableColumn, columnIdTo: TableColumn, edge: Edge | null) => { + const columns = useSettingsStore.getState().lists[itemListKey]?.table.columns; + + if (!columns) { + return; + } + + const indexFrom = columns.findIndex((column) => column.id === columnIdFrom); + const indexTo = columns.findIndex((column) => column.id === columnIdTo); + + // If either column not found or dragging to the same position, do nothing + if (indexFrom === -1 || indexTo === -1 || indexFrom === indexTo) { + return; + } + + const targetColumn = columns[indexTo]; + + // Create a new array to avoid mutating the original + const newColumns = [...columns]; + + // Remove the column from its current position + const [movedColumn] = newColumns.splice(indexFrom, 1); + + // Update pinned status based on target column + // If dragging onto a pinned left column, pin the moved column to left + // If dragging onto a pinned right column, pin the moved column to right + // If dragging onto an unpinned column, unpin the moved column + const updatedMovedColumn = + targetColumn.pinned === 'left' + ? { ...movedColumn, pinned: 'left' as const } + : targetColumn.pinned === 'right' + ? { ...movedColumn, pinned: 'right' as const } + : { ...movedColumn, pinned: null }; + + // Calculate the new insertion index based on edge + // After removing the item, indices shift: + // - If removing from before the target, target index decreases by 1 + // - If removing from after the target, target index stays the same + let newIndex: number; + + if (edge === 'left') { + // Insert before the target column + if (indexFrom < indexTo) { + // Removed item was before target, so target shifted left by 1 + newIndex = indexTo - 1; + } else { + // Removed item was after target, target index unchanged + newIndex = indexTo; + } + } else if (edge === 'right') { + // Insert after the target column + if (indexFrom < indexTo) { + // Removed item was before target, so target shifted left by 1 + newIndex = indexTo; + } else { + // Removed item was after target, target index unchanged + newIndex = indexTo + 1; + } + } else { + // No edge specified, default to inserting after the target position + if (indexFrom < indexTo) { + newIndex = indexTo; + } else { + newIndex = indexTo + 1; + } + } + + // Insert the column at the new position + newColumns.splice(newIndex, 0, updatedMovedColumn); + + setList(itemListKey, { + table: { + columns: newColumns, + }, + }); + }, + [itemListKey, setList], + ); + + return { handleColumnReordered }; +}; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index 576718fae..138f0b48a 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -191,6 +191,33 @@ padding: 0 var(--theme-spacing-xl); } +.header-dragging { + cursor: grabbing; + opacity: 0.5; +} + +.header-dragged-over-left::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 10; + width: 3px; + content: ''; + background-color: var(--theme-colors-primary); +} + +.header-dragged-over-right::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 10; + width: 3px; + content: ''; + background-color: var(--theme-colors-primary); +} + .header-content { display: flex; align-items: center; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 808ab5855..96823cf41 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -1,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 { useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; @@ -38,7 +49,13 @@ import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Text } from '/@/shared/components/text/text'; import { useDoubleClick } from '/@/shared/hooks/use-double-click'; import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; -import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; +import { + dndUtils, + DragData, + DragOperation, + DragTarget, + DragTargetMap, +} from '/@/shared/types/drag-and-drop'; import { TableColumn } from '/@/shared/types/types'; export interface ItemTableListColumn extends CellComponentProps {} @@ -808,15 +825,101 @@ export const TableColumnHeaderContainer = ( 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) { + 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, + }); + 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 isSelf = (args.source.data.id as string[])[0] === props.type; + return dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && !isSelf; + }, + element: containerRef.current, + getData: ({ element, input }) => { + const data = dndUtils.generateDragData({ + id: [props.type], + operation: [DragOperation.REORDER], + type: DragTarget.TABLE_COLUMN, + }); + + 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]); + return ( void; onColumnResized?: (columnId: TableColumn, width: number) => void; onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => void; onScrollEnd?: (offset: number, internalState: ItemListStateActions) => void; @@ -542,6 +552,7 @@ export const ItemTableList = ({ headerHeight = 40, initialTop, itemType, + onColumnReordered, onColumnResized, onRangeChanged, onScrollEnd, @@ -1372,6 +1383,7 @@ export const ItemTableList = ({ }, [imperativeHandle]); const controls = useDefaultItemListControls({ + onColumnReordered, onColumnResized, }); @@ -1390,6 +1402,7 @@ export const ItemTableList = ({ controls={controls} data={data} enableAlternateRowColors={enableAlternateRowColors} + enableColumnReorder={!!onColumnReordered} enableColumnResize={!!onColumnResized} enableDrag={enableDrag} enableExpansion={enableExpansion} diff --git a/src/renderer/components/item-list/types.ts b/src/renderer/components/item-list/types.ts index 0799255b1..61c957024 100644 --- a/src/renderer/components/item-list/types.ts +++ b/src/renderer/components/item-list/types.ts @@ -18,6 +18,15 @@ export interface DefaultItemControlProps { export interface ItemControls { onClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; + onColumnReordered?: ({ + columnIdFrom, + columnIdTo, + edge, + }: { + columnIdFrom: TableColumn; + columnIdTo: TableColumn; + edge: 'top' | 'bottom' | 'left' | 'right' | null; + }) => void; onColumnResized?: ({ columnId, width }: { columnId: TableColumn; width: number }) => void; onDoubleClick?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; onExpand?: ({ internalState, item, itemType }: DefaultItemControlProps) => void; diff --git a/src/renderer/features/albums/components/album-list-infinite-table.tsx b/src/renderer/features/albums/components/album-list-infinite-table.tsx index fba010e20..6c99674b3 100644 --- a/src/renderer/features/albums/components/album-list-infinite-table.tsx +++ b/src/renderer/features/albums/components/album-list-infinite-table.tsx @@ -3,6 +3,7 @@ import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { 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 { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; @@ -61,6 +62,10 @@ export const AlbumListInfiniteTable = forwardRef(({ listKey, sear const isEmpty = data.length === 0; + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: listKey, + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: listKey, + }); + usePlayerEvents( { onCurrentSongChange: (properties) => { @@ -86,6 +96,8 @@ export const PlayQueue = forwardRef(({ listKey, sear type: 'offset', }} itemType={LibraryItem.QUEUE_SONG} + onColumnReordered={handleColumnReordered} + onColumnResized={handleColumnResized} ref={mergedRef} size={table.size} /> diff --git a/src/renderer/features/songs/components/song-list-infinite-table.tsx b/src/renderer/features/songs/components/song-list-infinite-table.tsx index f1ed2d400..00d7c08c8 100644 --- a/src/renderer/features/songs/components/song-list-infinite-table.tsx +++ b/src/renderer/features/songs/components/song-list-infinite-table.tsx @@ -3,6 +3,7 @@ import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { 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 { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; @@ -56,6 +57,10 @@ export const SongListInfiniteTable = forwardRef enabled: saveScrollOffset, }); + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.SONG, + }); + const { handleColumnResized } = useItemListColumnResize({ itemListKey: ItemListKey.SONG, }); @@ -76,6 +81,7 @@ export const SongListInfiniteTable = forwardRef type: 'offset', }} itemType={LibraryItem.SONG} + onColumnReordered={handleColumnReordered} onColumnResized={handleColumnResized} onRangeChanged={onRangeChanged} onScrollEnd={handleOnScrollEnd} diff --git a/src/renderer/features/songs/components/song-list-paginated-table.tsx b/src/renderer/features/songs/components/song-list-paginated-table.tsx index a890b769b..bf2b7eec5 100644 --- a/src/renderer/features/songs/components/song-list-paginated-table.tsx +++ b/src/renderer/features/songs/components/song-list-paginated-table.tsx @@ -3,6 +3,7 @@ import { forwardRef } from 'react'; import { api } from '/@/renderer/api'; import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { 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 { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; @@ -60,6 +61,10 @@ export const SongListPaginatedTable = forwardRef