Replace "react-beautiful-dnd" with "dnd-kit"

"react-beautiful-dnd" has been deprecated for quite some time
This commit is contained in:
schroda
2025-04-18 14:28:06 +02:00
parent 55c4a92b26
commit 213f561fe4
10 changed files with 252 additions and 216 deletions

View File

@@ -0,0 +1,39 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import {
MouseSensor,
PointerSensorOptions,
Sensor,
SensorDescriptor,
TouchSensor,
useSensor,
SensorOptions,
useSensors,
} from '@dnd-kit/core';
import { AbstractPointerSensorOptions } from '@dnd-kit/core/dist/sensors';
import { MediaQuery } from '@/modules/core/utils/MediaQuery.tsx';
export class DndKitUtil {
static getSensorForDevice(): Sensor<PointerSensorOptions> {
if (MediaQuery.isTouchDevice()) {
return TouchSensor;
}
return MouseSensor;
}
static useSensorsForDevice(options?: AbstractPointerSensorOptions): SensorDescriptor<SensorOptions>[] {
return useSensors(
useSensor(DndKitUtil.getSensorForDevice(), {
...options,
activationConstraint: { distance: 15, ...options?.activationConstraint },
}),
);
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { DragOverlay } from '@dnd-kit/core';
import { ReactNode } from 'react';
export const DndOverlayItem = ({ isActive, children }: { isActive: boolean; children?: ReactNode }) => {
if (!isActive) {
return null;
}
return <DragOverlay style={{ cursor: 'grabbing' }}>{children}</DragOverlay>;
};

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import Box from '@mui/material/Box';
import { useSortable } from '@dnd-kit/sortable';
import { ReactNode } from 'react';
import { UniqueIdentifier } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { applyStyles } from '@/modules/core/utils/ApplyStyles.ts';
export const DndSortableItem = ({
id,
isDragging = false,
children,
}: {
id: UniqueIdentifier;
isDragging?: boolean;
children: ReactNode;
}) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
return (
<Box
ref={setNodeRef}
sx={{
transform: CSS.Translate.toString(transform),
transition,
...applyStyles(isDragging, {
opacity: 0.25,
}),
}}
{...attributes}
{...listeners}
>
{children}
</Box>
);
};

View File

@@ -7,7 +7,6 @@
*/
import IconButton from '@mui/material/IconButton';
import { DraggableProvided } from 'react-beautiful-dnd';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -23,11 +22,9 @@ import { CategoryType } from '@/lib/graphql/generated/graphql.ts';
export const CategorySettingsCard = ({
category,
provided,
onEdit,
}: {
category: Pick<CategoryType, 'id' | 'name'>;
provided: DraggableProvided;
onEdit: () => void;
}) => {
const { t } = useTranslation();
@@ -37,7 +34,7 @@ export const CategorySettingsCard = ({
};
return (
<Box sx={{ p: 1, pb: 0 }} {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef}>
<Box sx={{ p: 1, pb: 0 }}>
<Card>
<CardContent
sx={{

View File

@@ -6,8 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useLayoutEffect, useMemo, useState } from 'react';
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
import { ComponentProps, useLayoutEffect, useMemo, useState } from 'react';
import { useTheme } from '@mui/material/styles';
import Fab from '@mui/material/Fab';
import AddIcon from '@mui/icons-material/Add';
@@ -21,8 +20,9 @@ import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import { useTranslation } from 'react-i18next';
import Box from '@mui/material/Box';
import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { StrictModeDroppable } from '@/modules/core/components/StrictModeDroppable.tsx';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
@@ -31,9 +31,12 @@ import { GetCategoriesSettingsQuery, GetCategoriesSettingsQueryVariables } from
import { GET_CATEGORIES_SETTINGS } from '@/lib/graphql/queries/CategoryQuery.ts';
import { CategorySettingsCard } from '@/modules/category/components/CategorySettingsCard.tsx';
import { CategoryIdInfo } from '@/modules/category/Category.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { makeToast } from '@/modules/core/utils/Toast.ts';
import { DndSortableItem } from '@/lib/dnd-kit/DndSortableItem.tsx';
import { DndKitUtil } from '@/lib/dnd-kit/DndKitUtil.ts';
import { DndOverlayItem } from '@/lib/dnd-kit/DndOverlayItem.tsx';
export function CategorySettings() {
const { t } = useTranslation();
@@ -68,6 +71,11 @@ export function CategorySettings() {
const [reorderCategory, { reset: revertReorder }] = requestManager.useReorderCategory();
const theme = useTheme();
const dndSensors = DndKitUtil.useSensorsForDevice();
const [dndActiveCategory, setDndActiveCategory] = useState<
ComponentProps<typeof CategorySettingsCard>['category'] | null
>(null);
const categoryReorder = (list: CategoryIdInfo[], from: number, to: number) => {
const reorderedCategory = list[from];
@@ -76,13 +84,19 @@ export function CategorySettings() {
);
};
const onDragEnd = (result: DropResult) => {
// dropped outside the list?
if (!result.destination) {
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setDndActiveCategory(null);
if (!over || active.id === over.id) {
return;
}
categoryReorder(categories, result.source.index, result.destination.index);
const oldIndex = categories.findIndex((category) => category.id === active.id);
const newIndex = categories.findIndex((category) => category.id === over.id);
categoryReorder(categories, oldIndex, newIndex);
};
const resetDialog = () => {
@@ -142,26 +156,33 @@ export function CategorySettings() {
return (
<>
<DragDropContext onDragEnd={onDragEnd}>
<StrictModeDroppable droppableId="droppable">
{(droppableProvided) => (
<Box ref={droppableProvided.innerRef} sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }}>
{categories.map((category, index) => (
<Draggable key={category.id} draggableId={category.id.toString()} index={index}>
{(draggableProvided) => (
<CategorySettingsCard
provided={draggableProvided}
category={category}
onEdit={() => handleEditDialogOpen(index)}
/>
)}
</Draggable>
))}
{droppableProvided.placeholder}
</Box>
)}
</StrictModeDroppable>
</DragDropContext>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
onDragStart={(event) =>
setDndActiveCategory(categories.find((category) => category.id === event.active.id) ?? null)
}
onDragEnd={onDragEnd}
onDragCancel={() => setDndActiveCategory(null)}
onDragAbort={() => setDndActiveCategory(null)}
>
<Box sx={{ paddingBottom: DEFAULT_FULL_FAB_HEIGHT }}>
<SortableContext items={categories} strategy={verticalListSortingStrategy}>
{categories.map((category, index) => (
<DndSortableItem
key={category.id}
id={category.id}
isDragging={category.id === dndActiveCategory?.id}
>
<CategorySettingsCard category={category} onEdit={() => handleEditDialogOpen(index)} />
</DndSortableItem>
))}
</SortableContext>
<DndOverlayItem isActive={!!dndActiveCategory}>
<CategorySettingsCard category={dndActiveCategory!} onEdit={noOp} />
</DndOverlayItem>
</Box>
</DndContext>
<Fab
color="primary"
aria-label="add"

View File

@@ -1,31 +0,0 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useEffect, useState } from 'react';
import { Droppable, DroppableProps } from 'react-beautiful-dnd';
// issue: https://github.com/atlassian/react-beautiful-dnd/issues/2399
// credit for fix: https://github.com/atlassian/react-beautiful-dnd/issues/2399#issuecomment-1175638194
export function StrictModeDroppable({ children, ...props }: DroppableProps) {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
return <Droppable {...props}>{children}</Droppable>;
}

View File

@@ -19,6 +19,10 @@ export class MediaQuery {
static readonly TABLET_WIDTH: Breakpoint | number = 1025;
static isTouchDevice(): boolean {
return window.matchMedia('not (pointer: fine)').matches;
}
static useIsTouchDevice(): boolean {
return useMediaQuery('not (pointer: fine)');
}

View File

@@ -13,10 +13,9 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import Stack from '@mui/material/Stack';
import Box, { BoxProps } from '@mui/material/Box';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import React, { memo, useCallback, useEffect, useLayoutEffect } from 'react';
import { DragDropContext, Draggable, DraggableProvided, DropResult } from 'react-beautiful-dnd';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -24,9 +23,10 @@ import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import { Virtuoso } from 'react-virtuoso';
import CardContent from '@mui/material/CardContent';
import Refresh from '@mui/icons-material/Refresh';
import { closestCenter, DndContext, DragEndEvent } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { StrictModeDroppable } from '@/modules/core/components/StrictModeDroppable.tsx';
import { makeToast } from '@/modules/core/utils/Toast.ts';
import { DownloadStateIndicator } from '@/modules/core/components/DownloadStateIndicator.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
@@ -35,25 +35,19 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
import { ChapterDownloadStatus, ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts';
import { DownloaderState, DownloadState } from '@/lib/graphql/generated/graphql.ts';
import { AppRoutes } from '@/modules/core/AppRoute.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
const HeightPreservingItem = ({ children, ...props }: BoxProps) => (
// the height is necessary to prevent the item container from collapsing, which confuses Virtuoso measurements
<Box {...props} style={{ height: props['data-known-size' as keyof typeof props] || undefined }}>
{children}
</Box>
);
import { DndSortableItem } from '@/lib/dnd-kit/DndSortableItem.tsx';
import { DndKitUtil } from '@/lib/dnd-kit/DndKitUtil.ts';
import { DndOverlayItem } from '@/lib/dnd-kit/DndOverlayItem.tsx';
const DownloadChapterItem = memo(
({
provided,
item,
handleDelete,
handleRetry,
}: {
provided: DraggableProvided;
item: ChapterDownloadStatus;
handleDelete: (chapter: ChapterIdInfo) => void;
handleRetry: (chapter: ChapterIdInfo) => void;
@@ -61,12 +55,7 @@ const DownloadChapterItem = memo(
const { t } = useTranslation();
return (
<Box
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
sx={{ p: 1, pb: 0 }}
>
<Box sx={{ p: 1, pb: 0 }}>
<Card>
<CardActionArea component={Link} to={AppRoutes.manga.path(item.manga.id)}>
<CardContent
@@ -146,6 +135,10 @@ export const DownloadQueue: React.FC = () => {
const { setTitle, setAction } = useNavBarContext();
const dndItems = useMemo(() => queue.map((download) => download.chapter), [queue]);
const dndSensors = DndKitUtil.useSensorsForDevice();
const [dndActiveDownload, setDndActiveDownload] = useState<ChapterDownloadStatus | null>(null);
const clearQueue = async () => {
try {
await requestManager.clearDownloads().response;
@@ -216,12 +209,19 @@ export const DownloadQueue: React.FC = () => {
});
};
const onDragEnd = (result: DropResult) => {
if (!result.destination) {
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setDndActiveDownload(null);
if (!over || active.id === over.id) {
return;
}
categoryReorder(queue, result.source.index, result.destination.index);
const oldIndex = queue.findIndex((download) => download.chapter.id === active.id);
const newIndex = queue.findIndex((download) => download.chapter.id === over.id);
categoryReorder(queue, oldIndex, newIndex);
};
const handleRetry = useCallback(async (chapter: ChapterIdInfo) => {
@@ -278,45 +278,39 @@ export const DownloadQueue: React.FC = () => {
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<StrictModeDroppable
droppableId="droppable"
mode="virtual"
renderClone={(provided, snapshot, rubric) => (
<DownloadChapterItem
provided={provided}
item={queue[rubric.source.index]}
handleDelete={handleDelete}
handleRetry={handleRetry}
/>
)}
>
{(droppableProvided) => (
<Box ref={droppableProvided.innerRef}>
<Virtuoso
useWindowScroll
overscan={window.innerHeight * 0.5}
components={{
Item: HeightPreservingItem,
}}
totalCount={queue.length}
computeItemKey={(index) => queue[index].chapter.id}
itemContent={(index) => (
<Draggable draggableId={`${queue[index].chapter.id}`} index={index}>
{(draggableProvided) => (
<DownloadChapterItem
provided={draggableProvided}
item={queue[index]}
handleDelete={handleDelete}
handleRetry={handleRetry}
/>
)}
</Draggable>
)}
/>
</Box>
)}
</StrictModeDroppable>
</DragDropContext>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
onDragStart={(event) =>
setDndActiveDownload(queue.find((download) => download.chapter.id === event.active.id) ?? null)
}
onDragEnd={onDragEnd}
onDragCancel={() => setDndActiveDownload(null)}
onDragAbort={() => setDndActiveDownload(null)}
>
<SortableContext items={dndItems} strategy={verticalListSortingStrategy}>
<Virtuoso
useWindowScroll
overscan={window.innerHeight * 0.5}
totalCount={queue.length}
computeItemKey={(index) => queue[index].chapter.id}
itemContent={(index) => (
<DndSortableItem
id={queue[index].chapter.id}
isDragging={queue[index].chapter.id === dndActiveDownload?.chapter.id}
>
<DownloadChapterItem
item={queue[index]}
handleDelete={handleDelete}
handleRetry={handleRetry}
/>
</DndSortableItem>
)}
/>
</SortableContext>
<DndOverlayItem isActive={!!dndActiveDownload}>
<DownloadChapterItem item={dndActiveDownload!} handleDelete={noOp} handleRetry={noOp} />
</DndOverlayItem>
</DndContext>
);
};