diff --git a/package.json b/package.json index 51048e59..8065e642 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "release": "tauri build -t i686-pc-windows-msvc" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@react-native-clipboard/clipboard": "^1.14.1", "@tauri-apps/api": "^1.6.0", "axios": "^1.7.2", @@ -44,4 +47,4 @@ "typescript": "^5.3.3", "vite": "^5.3.3" } -} \ No newline at end of file +} diff --git a/src/containers/MainBody/ServerList/Item.tsx b/src/containers/MainBody/ServerList/Item.tsx index f2333ff9..f08a4d33 100644 --- a/src/containers/MainBody/ServerList/Item.tsx +++ b/src/containers/MainBody/ServerList/Item.tsx @@ -1,9 +1,10 @@ import { t } from "i18next"; -import { memo, useRef, useState } from "react"; -import { Pressable, StyleSheet, View, PanResponder } from "react-native"; +import { memo, useRef } from "react"; +import { Pressable, StyleSheet, View } from "react-native"; import Icon from "../../../components/Icon"; import Text from "../../../components/Text"; import { images } from "../../../constants/images"; +import { useDraggableItem } from "../../../hooks/draggable"; import { useContextMenu } from "../../../states/contextMenu"; import { useJoinServerPrompt } from "../../../states/joinServerPrompt"; import { useTheme } from "../../../states/theme"; @@ -16,102 +17,38 @@ interface IProps { isSelected?: boolean; onSelect?: (server: Server) => void; isDraggable?: boolean; - onDragStart?: (server: Server, index: number) => void; - onDragEnd?: () => void; - onDragMove?: (index: number, y: number) => void; - isDraggedOver?: boolean; isBeingDragged?: boolean; } const ServerItem = memo((props: IProps) => { - const { - server, - index, - isDraggable = false, - isDraggedOver = false, - isBeingDragged = false, - } = props; + const { server, index, isDraggable = false, isBeingDragged = false } = props; const { theme, themeType } = useTheme(); const lastPressTime = useRef(0); const { showPrompt, setServer } = useJoinServerPrompt(); const { show: showContextMenu } = useContextMenu(); - const [isDragging, setIsDragging] = useState(false); - const dragStartTime = useRef(0); - const hasStartedDragging = useRef(false); - - const panResponder = PanResponder.create({ - onStartShouldSetPanResponder: () => isDraggable, - - onMoveShouldSetPanResponder: (_, gestureState) => { - if (!isDraggable) return false; - - const distance = Math.sqrt( - gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy - ); - - if (hasStartedDragging.current) return true; - - if (distance > 2) { - const isMainlyVertical = - Math.abs(gestureState.dy) > Math.abs(gestureState.dx) * 0.5; - return isMainlyVertical; - } - - return false; - }, - - onPanResponderGrant: (evt) => { - if (isDraggable && props.onDragStart) { - hasStartedDragging.current = true; - setIsDragging(true); - document.body.style.cursor = "grabbing"; - dragStartTime.current = Date.now(); - props.onDragStart(server, index); - - if (props.onDragMove) { - props.onDragMove(index, evt.nativeEvent.pageY); - } - } - }, - - onPanResponderMove: (evt) => { - if (isDraggable && hasStartedDragging.current && props.onDragMove) { - props.onDragMove(index, evt.nativeEvent.pageY); - } - }, - - onPanResponderRelease: () => { - if (isDraggable && hasStartedDragging.current) { - hasStartedDragging.current = false; - setIsDragging(false); - document.body.style.cursor = ""; // Add this - if (props.onDragEnd) { - props.onDragEnd(); - } - } - }, - - onPanResponderTerminate: () => { - if (isDraggable && hasStartedDragging.current) { - hasStartedDragging.current = false; - setIsDragging(false); - document.body.style.cursor = ""; // Add this - if (props.onDragEnd) { - props.onDragEnd(); - } - } - }, - - onShouldBlockNativeResponder: () => - isDraggable && hasStartedDragging.current, - }); + const { attributes, listeners, setNodeRef, isDragging, style } = + useDraggableItem(`${server.ip}:${server.port}`, isDraggable); const onDoublePress = () => { setServer(server); showPrompt(true); }; + const onPress = () => { + if (isDragging) return; + + const delta = Date.now() - lastPressTime.current; + + if (delta < 500) { + lastPressTime.current = 0; + onDoublePress(); + } else { + lastPressTime.current = Date.now(); + props.onSelect?.(server); + } + }; + const getServerStatusIconColor = () => { if (server.hasPassword) { return "#eb4034"; @@ -142,182 +79,169 @@ const ServerItem = memo((props: IProps) => { } }; - const onPress = () => { - if (hasStartedDragging.current) return; - - var delta = new Date().getTime() - lastPressTime.current; - - if (delta < 500) { - lastPressTime.current = 0; - onDoublePress(); - } else { - lastPressTime.current = new Date().getTime(); - if (props.onSelect) { - props.onSelect(server); - } - } - }; - return ( - - {/* Drop indicator */} - {isDraggedOver && ( - - )} - - onPress()} - // @ts-ignore - onContextMenu={(e) => { - e.preventDefault(); - showContextMenu({ x: e.clientX, y: e.clientY }, server); - return e; - }} > - - - - - - {server.usingOmp && ( -
- - - -
- )} + onPress()} + // @ts-ignore + onContextMenu={(e) => { + e.preventDefault(); + showContextMenu({ x: e.clientX, y: e.clientY }, server); + return e; + }} + > + - - {server.hostname} - + + {server.usingOmp && ( +
+ + + +
+ )} - - {server.ping === 9999 ? "-" : server.ping} - - - - {server.gameMode} + {server.hostname} - - {server.playerCount} + - /{server.maxPlayers} + {server.ping === 9999 ? "-" : server.ping} - + + + + {server.gameMode} + + + + + {server.playerCount} + + /{server.maxPlayers} + + +
-
-
-
+ +
+ ); }); diff --git a/src/containers/MainBody/ServerList/Tabs/Favorites.tsx b/src/containers/MainBody/ServerList/Tabs/Favorites.tsx index 6372fdc8..b2e7bcd7 100644 --- a/src/containers/MainBody/ServerList/Tabs/Favorites.tsx +++ b/src/containers/MainBody/ServerList/Tabs/Favorites.tsx @@ -1,4 +1,21 @@ -import { useEffect, useMemo, useState, useRef } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core'; + +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; + import { useQuery } from "../../../../hooks/query"; import { useGenericTempState } from "../../../../states/genericStates"; import { usePersistentServers, useServers } from "../../../../states/servers"; @@ -6,19 +23,14 @@ import { sortAndSearchInServerList } from "../../../../utils/helpers"; import { Server } from "../../../../utils/types"; import List from "../List"; import ServerItem from "./../Item"; -import { sc } from "../../../../utils/sizeScaler"; const Favorites = () => { const { startQuery, stopQuery } = useQuery(); const { favorites, updateInFavoritesList, reorderFavorites } = usePersistentServers(); const { selected, setSelected } = useServers(); const { searchData } = useGenericTempState(); - - // Drag functionality state - const [draggedItem, setDraggedItem] = useState<{server: Server, index: number} | null>(null); - const [dragOverIndex, setDragOverIndex] = useState(null); - const itemHeight = sc(39); - const listRef = useRef(null); + + const [activeId, setActiveId] = useState(null); useEffect(() => { return () => { @@ -48,88 +60,107 @@ const Favorites = () => { favorites, ]); + const isDraggable = useMemo(() => { + return searchData.query === "" && + searchData.languages.length === 0 && + !searchData.ompOnly && + !searchData.nonEmpty && + !searchData.unpassworded && + searchData.sortPing === "none" && + searchData.sortPlayer === "none" && + searchData.sortName === "none" && + searchData.sortMode === "none"; + }, [searchData]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + const onSelect = (server: Server) => { stopQuery(); setSelected(server); startQuery(server, "favorites"); }; - const handleDragStart = (server: Server, index: number) => { - setDraggedItem({ server, index }); - setDragOverIndex(index); - }; + function handleDragStart(event: any) { + const { active } = event; + setActiveId(active.id); + } - const handleDragEnd = () => { - if (draggedItem && dragOverIndex !== null && draggedItem.index !== dragOverIndex) { - const draggedServer = draggedItem.server; - const targetServer = list[dragOverIndex]; - - const originalDraggedIndex = favorites.findIndex( - (fav) => fav.ip === draggedServer.ip && fav.port === draggedServer.port - ); - const originalTargetIndex = favorites.findIndex( - (fav) => fav.ip === targetServer.ip && fav.port === targetServer.port - ); - - if (originalDraggedIndex !== -1 && originalTargetIndex !== -1) { - reorderFavorites(originalDraggedIndex, originalTargetIndex); - } - } - setDraggedItem(null); - setDragOverIndex(null); - }; + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + + if (active.id !== over?.id) { + const oldIndex = list.findIndex((server) => `${server.ip}:${server.port}` === active.id); + const newIndex = list.findIndex((server) => `${server.ip}:${server.port}` === over?.id); + + if (oldIndex !== -1 && newIndex !== -1) { + const draggedServer = list[oldIndex]; + const targetServer = list[newIndex]; - const handleDragMove = (draggedIndex: number, y: number) => { - if (listRef.current && draggedItem) { - const listElement = listRef.current; - const listRect = listElement.getBoundingClientRect(); - const relativeY = y - listRect.top; - const headerHeight = 26; - const adjustedY = relativeY - headerHeight; - let newHoverIndex = Math.floor(adjustedY / itemHeight); - newHoverIndex = Math.max(0, Math.min(newHoverIndex, list.length - 1)); - - if (newHoverIndex !== draggedIndex && newHoverIndex !== dragOverIndex) { - setDragOverIndex(newHoverIndex); + const originalDraggedIndex = favorites.findIndex( + (fav) => fav.ip === draggedServer.ip && fav.port === draggedServer.port + ); + const originalTargetIndex = favorites.findIndex( + (fav) => fav.ip === targetServer.ip && fav.port === targetServer.port + ); + + if (originalDraggedIndex !== -1 && originalTargetIndex !== -1) { + reorderFavorites(originalDraggedIndex, originalTargetIndex); + } } } - }; - const isDraggable = useMemo(() => { - return searchData.query === "" && - searchData.languages.length === 0 && - !searchData.ompOnly && - !searchData.nonEmpty && - !searchData.unpassworded && - searchData.sortPing === "none" && - searchData.sortPlayer === "none" && - searchData.sortName === "none" && - searchData.sortMode === "none"; - }, [searchData]); + setActiveId(null); + } + + const serverIds = list.map(server => `${server.ip}:${server.port}`); return ( - ( - onSelect(server)} - isDraggable={isDraggable} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragMove={handleDragMove} - isDraggedOver={dragOverIndex === index} - isBeingDragged={draggedItem?.index === index} + ({ + ...args.transform, + x: 0, + }) + ]} + > + + ( + onSelect(server)} + isDraggable={isDraggable} + isBeingDragged={activeId === `${item.ip}:${item.port}`} + /> + )} /> - )} - /> + + ); }; diff --git a/src/hooks/draggable.ts b/src/hooks/draggable.ts new file mode 100644 index 00000000..8c37e856 --- /dev/null +++ b/src/hooks/draggable.ts @@ -0,0 +1,28 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +export const useDraggableItem = (id: string, isDraggable: boolean) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id, + disabled: !isDraggable, + animateLayoutChanges: () => false, + }); + + return { + attributes: isDraggable ? attributes : {}, + listeners: isDraggable ? listeners : {}, + setNodeRef, + isDragging, + style: { + transform: CSS.Transform.toString(transform), + transition, + }, + }; +}; diff --git a/yarn.lock b/yarn.lock index b5e3d922..be2fb9b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -841,6 +841,37 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" +"@dnd-kit/accessibility@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af" + integrity sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.3.1": + version "6.3.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.3.1.tgz#4c36406a62c7baac499726f899935f93f0e6d003" + integrity sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ== + dependencies: + "@dnd-kit/accessibility" "^3.1.1" + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/sortable@^10.0.0": + version "10.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz#1f9382b90d835cd5c65d92824fa9dafb78c4c3e8" + integrity sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg== + dependencies: + "@dnd-kit/utilities" "^3.2.2" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz#5a32b6af356dc5f74d61b37d6f7129a4040ced7b" + integrity sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg== + dependencies: + tslib "^2.0.0" + "@esbuild/aix-ppc64@0.21.5": version "0.21.5" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" @@ -4582,6 +4613,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tslib@^2.0.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^2.0.1: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"