feat: swith to pragmatic-drag-and-drop for better performance

This commit is contained in:
Ou 2024-11-10 22:36:27 +08:00
parent aee0e936b6
commit a1d25f7d6f
8 changed files with 276 additions and 180 deletions

View File

@ -25,13 +25,14 @@
"test": "vitest -c vitest.config.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@iconify-json/si": "^1.2.1",
"@tanstack/react-query-devtools": "^5.59.9",
"@tanstack/react-router": "^1.64.0",
"@unocss/reset": "^0.63.4",
"ahooks": "^3.8.1",
"better-sqlite3": "^11.3.0",
"cheerio": "^1.0.0",
"clsx": "^2.1.1",

138
pnpm-lock.yaml generated
View File

@ -17,15 +17,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.1.0
version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@dnd-kit/sortable':
specifier: ^8.0.0
version: 8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@18.3.1)
'@atlaskit/pragmatic-drag-and-drop':
specifier: ^1.4.0
version: 1.4.0
'@atlaskit/pragmatic-drag-and-drop-auto-scroll':
specifier: ^1.4.0
version: 1.4.0
'@atlaskit/pragmatic-drag-and-drop-hitbox':
specifier: ^1.0.3
version: 1.0.3
'@iconify-json/si':
specifier: ^1.2.1
version: 1.2.1
@ -38,6 +38,9 @@ importers:
'@unocss/reset':
specifier: ^0.63.4
version: 0.63.6
ahooks:
specifier: ^3.8.1
version: 3.8.1(react@18.3.1)
better-sqlite3:
specifier: ^11.3.0
version: 11.5.0
@ -244,6 +247,15 @@ packages:
peerDependencies:
ajv: '>=8'
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0':
resolution: {integrity: sha512-5GoikoTSW13UX76F9TDeWB8x3jbbGlp/Y+3aRkHe1MOBMkrWkwNpJ42MIVhhX/6NSeaZiPumP0KbGJVs2tOWSQ==}
'@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3':
resolution: {integrity: sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==}
'@atlaskit/pragmatic-drag-and-drop@1.4.0':
resolution: {integrity: sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==}
'@babel/code-frame@7.25.9':
resolution: {integrity: sha512-z88xeGxnzehn2sqZ8UdGQEvYErF1odv2CftxInpSYJt6uHuPe9YjahKZITGs3l5LeI9d2ROG+obuDAoSlqbNfQ==}
engines: {node: '>=6.9.0'}
@ -801,28 +813,6 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@dnd-kit/accessibility@3.1.0':
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.1.0':
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@8.0.0':
resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
peerDependencies:
'@dnd-kit/core': ^6.1.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@es-joy/jsdoccomment@0.48.0':
resolution: {integrity: sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==}
engines: {node: '>=16'}
@ -2634,6 +2624,12 @@ packages:
resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==}
engines: {node: '>= 14'}
ahooks@3.8.1:
resolution: {integrity: sha512-JoP9+/RWO7MnI/uSKdvQ8WB10Y3oo1PjLv+4Sv4Vpm19Z86VUMdXh+RhWvMGxZZs06sq2p0xVtFk8Oh5ZObsoA==}
engines: {node: '>=8.0.0'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@ -2765,6 +2761,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
bind-event-listener@3.0.0:
resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
@ -4064,6 +4063,9 @@ packages:
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==}
engines: {node: '>= 0.4'}
intersection-observer@0.12.2:
resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@ -4299,6 +4301,10 @@ packages:
js-cookie@2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
@ -4983,6 +4989,9 @@ packages:
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@ -5002,6 +5011,9 @@ packages:
peerDependencies:
react: ^18.3.1
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-remove-scroll-bar@2.3.6:
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'}
@ -6247,6 +6259,22 @@ snapshots:
jsonpointer: 5.0.1
leven: 3.1.0
'@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0':
dependencies:
'@atlaskit/pragmatic-drag-and-drop': 1.4.0
'@babel/runtime': 7.25.9
'@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3':
dependencies:
'@atlaskit/pragmatic-drag-and-drop': 1.4.0
'@babel/runtime': 7.25.9
'@atlaskit/pragmatic-drag-and-drop@1.4.0':
dependencies:
'@babel/runtime': 7.25.9
bind-event-listener: 3.0.0
raf-schd: 4.0.3
'@babel/code-frame@7.25.9':
dependencies:
'@babel/highlight': 7.25.9
@ -6954,31 +6982,6 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@dnd-kit/accessibility@3.1.0(react@18.3.1)':
dependencies:
react: 18.3.1
tslib: 2.8.0
'@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@dnd-kit/accessibility': 3.1.0(react@18.3.1)
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.0
'@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
dependencies:
'@dnd-kit/core': 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
react: 18.3.1
tslib: 2.8.0
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
dependencies:
react: 18.3.1
tslib: 2.8.0
'@es-joy/jsdoccomment@0.48.0':
dependencies:
comment-parser: 1.4.1
@ -8694,6 +8697,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
ahooks@3.8.1(react@18.3.1):
dependencies:
'@babel/runtime': 7.25.9
dayjs: 1.11.13(patch_hash=4wu3h3hwxidwuv4ovjl53zavle)
intersection-observer: 0.12.2
js-cookie: 3.0.5
lodash: 4.17.21
react: 18.3.1
react-fast-compare: 3.2.2
resize-observer-polyfill: 1.5.1
screenfull: 5.2.0
tslib: 2.8.0
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@ -8849,6 +8865,8 @@ snapshots:
binary-extensions@2.3.0: {}
bind-event-listener@3.0.0: {}
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
@ -10437,6 +10455,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.0.6
intersection-observer@0.12.2: {}
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
@ -10641,6 +10661,8 @@ snapshots:
js-cookie@2.2.1: {}
js-cookie@3.0.5: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
@ -11407,6 +11429,8 @@ snapshots:
radix3@1.1.2: {}
raf-schd@4.0.3: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@ -11431,6 +11455,8 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
react-fast-compare@3.2.2: {}
react-remove-scroll-bar@2.3.6(@types/react@18.3.12)(react@18.3.1):
dependencies:
react: 18.3.1

View File

@ -1,7 +1,6 @@
import type { NewsItem, SourceID, SourceResponse } from "@shared/types"
import { useQuery } from "@tanstack/react-query"
import { AnimatePresence, motion, useInView } from "framer-motion"
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
import { useWindowSize } from "react-use"
import { forwardRef, useImperativeHandle } from "react"
import { OverlayScrollbar } from "../common/overlay-scrollbar"
@ -12,23 +11,23 @@ export interface ItemsProps extends React.HTMLAttributes<HTMLDivElement> {
/**
*
*/
isDragged?: boolean
handleListeners?: SyntheticListenerMap
isDragging?: boolean
setHandleRef?: (ref: HTMLElement | null) => void
}
interface NewsCardProps {
id: SourceID
handleListeners?: SyntheticListenerMap
setHandleRef?: (ref: HTMLElement | null) => void
}
export const CardWrapper = forwardRef<HTMLDivElement, ItemsProps>(({ id, isDragged, handleListeners, style, ...props }, dndRef) => {
export const CardWrapper = forwardRef<HTMLElement, ItemsProps>(({ id, isDragging, setHandleRef, style, ...props }, dndRef) => {
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, {
once: true,
})
useImperativeHandle(dndRef, () => ref.current!)
useImperativeHandle(dndRef, () => ref.current! as HTMLDivElement)
return (
<div
@ -36,7 +35,7 @@ export const CardWrapper = forwardRef<HTMLDivElement, ItemsProps>(({ id, isDragg
className={$(
"flex flex-col h-500px rounded-2xl p-4 cursor-default",
"backdrop-blur-5 transition-opacity-300",
isDragged && "op-50",
isDragging && "op-50",
`bg-${sources[id].color}-500 dark:bg-${sources[id].color} bg-op-40!`,
)}
style={{
@ -45,12 +44,12 @@ export const CardWrapper = forwardRef<HTMLDivElement, ItemsProps>(({ id, isDragg
}}
{...props}
>
{inView && <NewsCard id={id} handleListeners={handleListeners} />}
{inView && <NewsCard id={id} setHandleRef={setHandleRef} />}
</div>
)
})
function NewsCard({ id, handleListeners }: NewsCardProps) {
function NewsCard({ id, setHandleRef }: NewsCardProps) {
const { refresh } = useRefetch()
const { data, isFetching, isError } = useQuery({
queryKey: ["source", id],
@ -141,9 +140,9 @@ function NewsCard({ id, handleListeners }: NewsCardProps) {
className={$("btn", isFocused ? "i-ph:star-fill" : "i-ph:star-duotone")}
onClick={toggleFocus}
/>
{handleListeners && (
{setHandleRef && (
<button
{...handleListeners}
ref={setHandleRef}
type="button"
className={$("btn", "i-ph:dots-six-vertical-duotone", "cursor-grab")}
/>

View File

@ -1,26 +1,18 @@
import type { PropsWithChildren } from "react"
import { useCallback, useState } from "react"
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"
import {
DndContext,
DragOverlay,
MeasuringStrategy,
MouseSensor,
TouchSensor,
closestCenter,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
} from "@dnd-kit/core"
import type { AnimateLayoutChanges } from "@dnd-kit/sortable"
import { SortableContext, arrayMove, defaultAnimateLayoutChanges, rectSortingStrategy, useSortable } from "@dnd-kit/sortable"
import type { SourceID } from "@shared/types"
import { CSS } from "@dnd-kit/utilities"
import { motion } from "framer-motion"
import type { BaseEventPayload, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { reorderWithEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge"
import { createPortal } from "react-dom"
import { useThrottleFn } from "ahooks"
import { DndContext } from "../common/dnd"
import { useSortable } from "../common/dnd/useSortable"
import type { ItemsProps } from "./card"
import { CardWrapper } from "./card"
import { currentSourcesAtom } from "~/atoms"
const AnimationDuration = 200
export function Dnd() {
const [items, setItems] = useAtom(currentSourcesAtom)
useEntireQuery(items)
@ -50,17 +42,17 @@ export function Dnd() {
{items.map(id => (
<motion.li
key={id}
layout
transition={{
type: "tween",
duration: AnimationDuration / 1000,
}}
variants={{
hidden: {
y: 20,
opacity: 0,
display: "none",
},
visible: {
display: "block",
y: 0,
opacity: 1,
},
@ -74,65 +66,35 @@ export function Dnd() {
)
}
interface DndProps {
function DndWrapper({ items, setItems, children }: PropsWithChildren<{
items: SourceID[]
setItems: (update: SourceID[]) => void
}
function DndWrapper({ items, setItems, children }: PropsWithChildren<DndProps>) {
const [activeId, setActiveId] = useState<string | null>(null)
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor))
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string)
}, [])
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = items.indexOf(active.id as any)
const newIndex = items.indexOf(over!.id as any)
setItems(arrayMove(items, oldIndex, newIndex))
}
setActiveId(null)
}, [setItems, items])
const handleDragCancel = useCallback(() => {
setActiveId(null)
}, [])
setItems: (items: SourceID[]) => void
}>) {
const onDropTargetChange = useCallback(({ location, source }: BaseEventPayload<ElementDragType>) => {
const traget = location.current.dropTargets[0]
if (!traget?.data || !source?.data) return
const closestEdgeOfTarget = extractClosestEdge(traget.data)
const fromIndex = items.indexOf(source.data.id as SourceID)
const toIndex = items.indexOf(traget.data.id as SourceID)
if (fromIndex === toIndex || fromIndex === -1 || toIndex === -1) return
const update = reorderWithEdge({
list: items,
startIndex: fromIndex,
indexOfTarget: toIndex,
closestEdgeOfTarget,
axis: "vertical",
})
setItems(update)
}, [items, setItems])
// 避免动画干扰
const { run } = useThrottleFn(onDropTargetChange, {
leading: true,
trailing: true,
wait: AnimationDuration,
})
return (
<DndContext
sensors={sensors}
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
},
}}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
{children}
</SortableContext>
<DragOverlay
className="transition-opacity-300"
dropAnimation={{
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
duration: 300,
sideEffects: defaultDropAnimationSideEffects({
className: {
active: "op-100",
dragOverlay: "op-0",
},
}),
}}
>
{!!activeId && <CardOverlay id={activeId as SourceID} />}
</DragOverlay>
<DndContext onDropTargetChange={run}>
{children}
</DndContext>
)
}
@ -140,8 +102,9 @@ function DndWrapper({ items, setItems, children }: PropsWithChildren<DndProps>)
function CardOverlay({ id }: { id: SourceID }) {
return (
<div className={$(
"flex flex-col rounded-2xl p-4 backdrop-blur-5",
"flex flex-col p-4 backdrop-blur-5",
`bg-${sources[id].color}-500 dark:bg-${sources[id].color} bg-op-40!`,
!isiOS() && "rounded-2xl",
)}
>
<div className={$("flex justify-between mx-2 items-center")}>
@ -173,45 +136,29 @@ function CardOverlay({ id }: { id: SourceID }) {
)
}
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args
if (isSorting || wasDragging) {
return defaultAnimateLayoutChanges(args)
}
return true
}
function SortableCardWrapper({ id, ...props }: ItemsProps) {
function SortableCardWrapper({ id }: ItemsProps) {
const {
isDragging,
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id,
animateLayoutChanges,
transition: {
duration: 300,
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
},
})
setHandleRef,
OverlayContainer,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
useEffect(() => {
if (OverlayContainer) {
OverlayContainer!.className += $(`bg-base`, !isiOS() && "rounded-2xl")
}
}, [OverlayContainer])
return (
<CardWrapper
ref={setNodeRef}
id={id}
style={style}
isDragged={isDragging}
handleListeners={listeners}
{...attributes}
{...props}
/>
<>
<CardWrapper
ref={setNodeRef}
id={id}
isDragging={isDragging}
setHandleRef={setHandleRef}
/>
{OverlayContainer && createPortal(<CardOverlay id={id} />, OverlayContainer)}
</>
)
}

View File

@ -0,0 +1,28 @@
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import type { PropsWithChildren } from "react"
import type { AllEvents, ElementDragType } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"
import { InstanceIdContext } from "./useSortable"
interface ContextProps extends Partial<AllEvents<ElementDragType>> {
}
export function DndContext({ children, ...callback }: PropsWithChildren<ContextProps>) {
const [instanceId] = useState<string>(randomUUID())
useEffect(() => {
return (
combine(
monitorForElements({
canMonitor({ source }) {
return source.data.instanceId === instanceId
},
...callback,
}),
)
)
}, [callback, instanceId])
return (
<InstanceIdContext.Provider value={instanceId}>
{children}
</InstanceIdContext.Provider>
)
}

View File

@ -0,0 +1,78 @@
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"
import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source"
import { createContext } from "react"
export const InstanceIdContext = createContext<string | null>(null)
interface SortableProps {
id: string
}
interface DraggableState {
type: "idle" | "dragging"
container?: HTMLElement
}
export function useSortable(props: SortableProps) {
const instanceId = useContext(InstanceIdContext)
const [draggableState, setDraggableState] = useState<DraggableState>({
type: "idle",
})
useEffect(() => {
if (draggableState.type === "idle") {
document.querySelector("html")?.classList.remove("grabbing")
} else if (draggableState.type === "dragging") {
// https://github.com/SortableJS/Vue.Draggable/issues/815#issuecomment-1552904628
setTimeout(() => {
document.querySelector("html")?.classList.add("grabbing")
}, 50)
}
}, [draggableState])
const [handleRef, setHandleRef] = useState<HTMLElement | null>(null)
const [nodeRef, setNodeRef] = useState<HTMLElement | null>(null)
useEffect(() => {
if (handleRef && nodeRef) {
const cleanup = combine(
draggable({
// use custom drag preview
element: handleRef,
// element: ref,
// dragHandle: handleRef,
getInitialData: () => ({ id: props.id, instanceId }),
onGenerateDragPreview({ nativeSetDragImage, location }) {
setCustomNativeDragPreview({
getOffset: preserveOffsetOnSource({
element: nodeRef,
input: location.current.input,
}),
render({ container }) {
container.style.width = `${nodeRef.clientWidth}px`
setDraggableState({ type: "dragging", container })
},
nativeSetDragImage,
})
},
onDrop: () => {
setDraggableState({ type: "idle" })
},
}),
dropTargetForElements({
element: nodeRef,
getData: () => ({ id: props.id }),
getIsSticky: () => true,
canDrop: ({ source }) => source.data.instanceId === instanceId,
}),
)
return cleanup
}
}, [props.id, instanceId, handleRef, nodeRef])
return {
setHandleRef,
setNodeRef,
isDragging: draggableState.type === "dragging",
OverlayContainer: draggableState.container,
}
}

View File

@ -51,3 +51,8 @@ button:disabled {
#dropdown-menu li {
--at-apply: hover:bg-neutral-400/10 rounded-md flex items-center p-1 gap-1;
}
.grabbing * {
cursor: grabbing;
}

View File

@ -42,3 +42,15 @@ export const myFetch = $fetch.create({
retry: 0,
baseURL: "/api",
})
export function isiOS() {
return [
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform)
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
}