add draggable table column reorder

This commit is contained in:
jeffvli
2025-11-14 11:18:27 -08:00
parent 4c92da9ab5
commit a03ea3b4d8
17 changed files with 340 additions and 3 deletions
@@ -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;
};
@@ -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 };
};
@@ -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;
@@ -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<TableItemProps> {}
@@ -808,15 +825,101 @@ export const TableColumnHeaderContainer = (
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) {
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 (
<Flex
className={clsx(styles.container, styles.headerContainer, props.containerClassName, {
[styles.headerDraggedOverLeft]: isDraggedOver === 'left',
[styles.headerDraggedOverRight]: isDraggedOver === 'right',
[styles.headerDragging]: isDragging,
[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
@@ -90,6 +90,7 @@ interface VirtualizedTableGridProps {
controls: ItemControls;
data: unknown[];
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag?: boolean;
enableExpansion: boolean;
@@ -128,6 +129,7 @@ const VirtualizedTableGrid = React.memo(
controls,
data,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
@@ -169,6 +171,7 @@ const VirtualizedTableGrid = React.memo(
controls,
data: enableHeader ? [null, ...data] : data,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
@@ -191,6 +194,7 @@ const VirtualizedTableGrid = React.memo(
enableHeader,
data,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
@@ -475,6 +479,7 @@ export interface TableItemProps {
controls: ItemControls;
data: ItemTableListProps['data'];
enableAlternateRowColors?: ItemTableListProps['enableAlternateRowColors'];
enableColumnReorder?: boolean;
enableColumnResize?: boolean;
enableDrag?: ItemTableListProps['enableDrag'];
enableExpansion?: ItemTableListProps['enableExpansion'];
@@ -515,6 +520,11 @@ interface ItemTableListProps {
type: 'index' | 'offset';
};
itemType: LibraryItem;
onColumnReordered?: (
columnIdFrom: TableColumn,
columnIdTo: TableColumn,
edge: 'top' | 'bottom' | 'left' | 'right' | null,
) => 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}
@@ -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;