SortableList: switch to clauderic/dnd-kit

This commit is contained in:
Jesse Chan
2022-05-10 22:14:31 -07:00
parent 0f80b596d8
commit 652f7d6e07
6 changed files with 223 additions and 425 deletions
@@ -1,42 +1,38 @@
import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
import classnames from 'classnames';
import {HTML5toTouch} from 'rdndmb-html5-to-touch';
import {DndProvider} from 'react-dnd-multi-backend';
import {DndContext, KeyboardSensor, MouseSensor, TouchSensor, useSensor} from '@dnd-kit/core';
import {FC, MouseEvent, ReactNode, useState} from 'react';
import {restrictToParentElement, restrictToVerticalAxis} from '@dnd-kit/modifiers';
import SortableListItem from './SortableListItem';
export type ListItem = {
id: string;
visible: boolean;
};
interface SortableListProps {
id: string;
className: string;
lockedIDs: Array<string>;
items: Array<ListItem>;
renderItem: (item: ListItem, index: number) => ReactNode;
items: string[];
renderItem: (id: string, index: number) => ReactNode;
onMouseDown?: (event: MouseEvent) => void;
onMove?: (items: this['items']) => void;
onDrop?: (items: this['items']) => void;
}
const SortableList: FC<SortableListProps> = ({
className,
id: listID,
items,
lockedIDs,
renderItem,
onMouseDown,
onMove,
onDrop,
}: SortableListProps) => {
const [currentItems, setCurrentItems] = useState(items);
const classes = classnames('sortable-list', className);
const keyboardSensor = useSensor(KeyboardSensor);
const mouseSensor = useSensor(MouseSensor, {activationConstraint: {distance: 10}});
const touchSensor = useSensor(TouchSensor, {activationConstraint: {distance: 10}});
return (
<div
css={{width: '100%'}}
css={{width: '100%', touchAction: 'none'}}
role="none"
onMouseDown={(event) => {
if (onMouseDown) {
@@ -44,53 +40,50 @@ const SortableList: FC<SortableListProps> = ({
}
}}
>
<DndProvider options={HTML5toTouch}>
<ul className={classes}>
{currentItems.map((item, index) => {
const {id, visible} = item;
return (
<SortableListItem
list={listID}
id={id}
index={index}
isLocked={lockedIDs.includes(id)}
isVisible={visible}
key={id}
onDrop={() => {
if (onDrop) {
onDrop(currentItems);
}
}}
onMove={(dragIndex, hoverIndex) => {
const draggedItem = currentItems[dragIndex];
<DndContext
sensors={[keyboardSensor, mouseSensor, touchSensor]}
onDragEnd={({active, over}) => {
if (over == null) {
return;
}
const newItems = currentItems.slice();
if (active.id === over.id) {
return;
}
// Remove the item being dragged.
newItems.splice(dragIndex, 1);
// Add the item being dragged in its new position.
newItems.splice(hoverIndex, 0, draggedItem);
const newItems = arrayMove(
items,
items.findIndex((id) => id === active.id),
items.findIndex((id) => id === over.id),
);
setCurrentItems(newItems);
setCurrentItems(newItems);
if (onMove) {
onMove(newItems);
}
}}
>
{renderItem(item, index)}
</SortableListItem>
);
})}
</ul>
</DndProvider>
if (onDrop) {
onDrop(newItems);
}
}}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
autoScroll={false}
>
<SortableContext items={currentItems} strategy={verticalListSortingStrategy}>
<ul className={classes}>
{currentItems.map((id, index) => {
return (
<SortableListItem id={id} disabled={lockedIDs.includes(id)} key={id}>
{renderItem(id, index)}
</SortableListItem>
);
})}
</ul>
</SortableContext>
</DndContext>
</div>
);
};
SortableList.defaultProps = {
onMouseDown: undefined,
onMove: undefined,
onDrop: undefined,
};
@@ -1,102 +1,40 @@
import classnames from 'classnames';
import {DragElementWrapper, DragPreviewOptions, DragSource, DragSourceOptions, DropTarget} from 'react-dnd';
import {FC, ReactNode, useEffect} from 'react';
import flow from 'lodash/flow';
import {getEmptyImage} from 'react-dnd-html5-backend';
import {CSS} from '@dnd-kit/utilities';
import {FC, ReactNode} from 'react';
import {useSortable} from '@dnd-kit/sortable';
import {Lock} from '@client/ui/icons';
interface SortableListItemProps {
children?: ReactNode;
list: string;
id: string;
index: number;
isVisible: boolean;
isDragging?: boolean;
isLocked?: boolean;
onDrop: () => void;
onMove: (sourceIndex: number, targetIndex: number) => void;
connectDragPreview: DragElementWrapper<DragPreviewOptions>;
connectDragSource: DragElementWrapper<DragSourceOptions>;
connectDropTarget: DragElementWrapper<never>;
disabled?: boolean;
}
const SortableListItem: FC<SortableListItemProps> = (props: SortableListItemProps) => {
const {children, isDragging, isLocked, connectDragPreview, connectDragSource, connectDropTarget} = props;
const {children, id, disabled} = props;
const {attributes, setNodeRef, listeners, transform, transition, isDragging} = useSortable({id, disabled});
useEffect(() => {
connectDragPreview(getEmptyImage(), {
captureDraggingState: true,
});
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return connectDragSource(
connectDropTarget(
<div
className={classnames('sortable-list__item', {
'sortable-list__item--is-dragging': isDragging,
'sortable-list__item--is-locked': isLocked,
})}
>
{isLocked ? <Lock /> : null}
{children}
</div>,
),
return (
<div
className={classnames('sortable-list__item', {
'sortable-list__item--is-dragging': isDragging,
'sortable-list__item--is-locked': disabled,
})}
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
{disabled ? <Lock /> : null}
{children}
</div>
);
};
export default flow([
DragSource(
'globally-draggable-item',
{
beginDrag({list, id, index, isVisible}: SortableListItemProps) {
return {list, id, index, isVisible};
},
canDrag({isLocked}: SortableListItemProps) {
if (isLocked) {
return false;
}
return true;
},
},
(connect, monitor) => ({
connectDragPreview: connect.dragPreview(),
connectDragSource: connect.dragSource(),
isDragging: monitor.isDragging(),
}),
),
DropTarget(
'globally-draggable-item',
{
drop({onDrop}: SortableListItemProps) {
if (onDrop) {
onDrop();
}
},
hover(props, monitor) {
const item: SortableListItemProps = monitor.getItem();
// Don't replace items with themselves
if (props.isLocked || item.index === props.index) {
return;
}
// Don't drop item to another list
if (item.list !== props.list) {
return;
}
props.onMove(item.index, props.index);
item.index = props.index;
},
},
(connect) => ({
connectDropTarget: connect.dropTarget(),
}),
),
])(SortableListItem) as FC<
Omit<SortableListItemProps, 'connectDragPreview' | 'connectDragSource' | 'connectDropTarget'>
>;
export default SortableListItem;
@@ -4,7 +4,7 @@ import {Trans} from '@lingui/react';
import {Checkbox} from '@client/ui';
import DiskUsageStore from '@client/stores/DiskUsageStore';
import SettingStore from '@client/stores/SettingStore';
import SortableList, {ListItem} from '@client/components/general/SortableList';
import SortableList from '@client/components/general/SortableList';
import type {FloodSettings} from '@shared/types/FloodSettings';
@@ -13,8 +13,8 @@ interface MountPointsListProps {
}
const MountPointsList: FC<MountPointsListProps> = ({onSettingsChange}: MountPointsListProps) => {
const [diskItems, setDiskItems] = useState<ListItem[]>(
((): ListItem[] => {
const [diskItems, setDiskItems] = useState(
(() => {
const {mountPoints} = SettingStore.floodSettings;
const disks = Object.assign(
{},
@@ -40,24 +40,28 @@ const MountPointsList: FC<MountPointsListProps> = ({onSettingsChange}: MountPoin
})(),
);
const diskItemVisiblity = diskItems.reduce((memo, {id, visible}) => {
memo[id] = visible;
return memo;
}, {} as Record<string, boolean>);
return (
<SortableList
id="disks"
className="sortable-list--disks"
items={diskItems.slice()}
items={diskItems.map(({id}) => id)}
lockedIDs={[]}
onDrop={(items: Array<ListItem>): void => {
setDiskItems(items);
onDrop={(items) => {
setDiskItems(items.map((id) => ({id, visible: diskItemVisiblity[id]})));
}}
renderItem={(item: ListItem) => {
const {id, visible} = item;
renderItem={(id) => {
const checkbox = (
<span className="sortable-list__content sortable-list__content--secondary">
<Checkbox
defaultChecked={visible}
defaultChecked={diskItemVisiblity[id]}
id={id}
onClick={(event) => {
diskItemVisiblity[id] = (event.target as HTMLInputElement).checked;
const newItems = diskItems.map((disk) => {
if (disk.id === id) {
return {...disk, visible: (event.target as HTMLInputElement).checked};
@@ -1,4 +1,4 @@
import {FC, ReactNode, useRef, useState} from 'react';
import {FC, useRef, useState} from 'react';
import {Trans} from '@lingui/react';
import {Checkbox} from '@client/ui';
@@ -7,12 +7,11 @@ import SettingStore from '@client/stores/SettingStore';
import TorrentListColumns from '@client/constants/TorrentListColumns';
import type {FloodSettings} from '@shared/types/FloodSettings';
import type {TorrentListColumn} from '@client/constants/TorrentListColumns';
import SortableList from '../../../general/SortableList';
import Tooltip from '../../../general/Tooltip';
import type {ListItem} from '../../../general/SortableList';
interface TorrentListColumnsListProps {
torrentListViewSize: FloodSettings['torrentListViewSize'];
onSettingsChange: (changedSettings: Partial<FloodSettings>) => void;
@@ -23,6 +22,7 @@ const TorrentListColumnsList: FC<TorrentListColumnsListProps> = ({
onSettingsChange,
}: TorrentListColumnsListProps) => {
const tooltipRef = useRef<Tooltip>(null);
const [torrentListColumns, setTorrentListColumns] = useState([
...SettingStore.floodSettings.torrentListColumns.filter((column) => TorrentListColumns[column.id] != null).slice(),
...Object.keys(TorrentListColumns)
@@ -33,25 +33,28 @@ const TorrentListColumnsList: FC<TorrentListColumnsListProps> = ({
})),
]);
const torrentListColumnVisiblity = torrentListColumns.reduce((memo, {id, visible}) => {
memo[id] = visible;
return memo;
}, {} as Record<string, boolean>);
const lockedIDs =
torrentListViewSize === 'expanded' ? ['name', 'eta', 'downRate', 'percentComplete', 'downTotal', 'upRate'] : [];
return (
<SortableList
id="torrent-details"
className="sortable-list--torrent-details"
items={torrentListColumns}
items={torrentListColumns.map(({id}) => id)}
lockedIDs={lockedIDs}
onMouseDown={(): void => {
tooltipRef.current?.dismissTooltip();
}}
onDrop={(items: Array<ListItem>): void => {
const newItems = items.slice();
onDrop={(items) => {
const newItems = items.map((id) => ({id, visible: torrentListColumnVisiblity[id]}));
onSettingsChange({torrentListColumns: newItems as FloodSettings['torrentListColumns']});
setTorrentListColumns(newItems);
}}
renderItem={(item: ListItem, index: number): ReactNode => {
const {id, visible} = item as FloodSettings['torrentListColumns'][number];
renderItem={(id, index) => {
let checkbox = null;
let warning = null;
@@ -59,17 +62,20 @@ const TorrentListColumnsList: FC<TorrentListColumnsListProps> = ({
checkbox = (
<span className="sortable-list__content sortable-list__content--secondary">
<Checkbox
defaultChecked={visible}
defaultChecked={torrentListColumnVisiblity[id]}
id={id}
onClick={(event) => {
torrentListColumnVisiblity[id] = (event.target as HTMLInputElement).checked;
const changedTorrentListColumns = torrentListColumns.map((column) => ({
id: column.id,
visible: column.id === id ? (event.target as HTMLInputElement).checked : column.visible,
}));
setTorrentListColumns(changedTorrentListColumns);
onSettingsChange({
torrentListColumns: changedTorrentListColumns as FloodSettings['torrentListColumns'],
});
setTorrentListColumns(changedTorrentListColumns);
}}
>
<Trans id="settings.ui.torrent.details.enabled" />
@@ -100,7 +106,7 @@ const TorrentListColumnsList: FC<TorrentListColumnsListProps> = ({
<div className="sortable-list__content sortable-list__content__wrapper">
{warning}
<span className="sortable-list__content sortable-list__content--primary">
<Trans id={TorrentListColumns[id]} />
<Trans id={TorrentListColumns[id as TorrentListColumn]} />
</span>
{checkbox}
</div>