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

138
pnpm-lock.yaml generated
View File

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

View File

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

View File

@ -1,26 +1,18 @@
import type { PropsWithChildren } from "react" 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 type { SourceID } from "@shared/types"
import { CSS } from "@dnd-kit/utilities"
import { motion } from "framer-motion" 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 type { ItemsProps } from "./card"
import { CardWrapper } from "./card" import { CardWrapper } from "./card"
import { currentSourcesAtom } from "~/atoms" import { currentSourcesAtom } from "~/atoms"
const AnimationDuration = 200
export function Dnd() { export function Dnd() {
const [items, setItems] = useAtom(currentSourcesAtom) const [items, setItems] = useAtom(currentSourcesAtom)
useEntireQuery(items) useEntireQuery(items)
@ -50,17 +42,17 @@ export function Dnd() {
{items.map(id => ( {items.map(id => (
<motion.li <motion.li
key={id} key={id}
layout
transition={{ transition={{
type: "tween", type: "tween",
duration: AnimationDuration / 1000,
}} }}
variants={{ variants={{
hidden: { hidden: {
y: 20, y: 20,
opacity: 0, opacity: 0,
display: "none",
}, },
visible: { visible: {
display: "block",
y: 0, y: 0,
opacity: 1, opacity: 1,
}, },
@ -74,65 +66,35 @@ export function Dnd() {
) )
} }
interface DndProps { function DndWrapper({ items, setItems, children }: PropsWithChildren<{
items: SourceID[] items: SourceID[]
setItems: (update: SourceID[]) => void setItems: (items: SourceID[]) => void
} }>) {
const onDropTargetChange = useCallback(({ location, source }: BaseEventPayload<ElementDragType>) => {
function DndWrapper({ items, setItems, children }: PropsWithChildren<DndProps>) { const traget = location.current.dropTargets[0]
const [activeId, setActiveId] = useState<string | null>(null) if (!traget?.data || !source?.data) return
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor)) const closestEdgeOfTarget = extractClosestEdge(traget.data)
const fromIndex = items.indexOf(source.data.id as SourceID)
const handleDragStart = useCallback((event: DragStartEvent) => { const toIndex = items.indexOf(traget.data.id as SourceID)
setActiveId(event.active.id as string) if (fromIndex === toIndex || fromIndex === -1 || toIndex === -1) return
}, []) const update = reorderWithEdge({
const handleDragEnd = useCallback((event: DragEndEvent) => { list: items,
const { active, over } = event startIndex: fromIndex,
indexOfTarget: toIndex,
if (active.id !== over?.id) { closestEdgeOfTarget,
const oldIndex = items.indexOf(active.id as any) axis: "vertical",
const newIndex = items.indexOf(over!.id as any) })
setItems(arrayMove(items, oldIndex, newIndex)) setItems(update)
} }, [items, setItems])
// 避免动画干扰
setActiveId(null) const { run } = useThrottleFn(onDropTargetChange, {
}, [setItems, items]) leading: true,
trailing: true,
const handleDragCancel = useCallback(() => { wait: AnimationDuration,
setActiveId(null) })
}, [])
return ( return (
<DndContext <DndContext onDropTargetChange={run}>
sensors={sensors} {children}
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> </DndContext>
) )
} }
@ -140,8 +102,9 @@ function DndWrapper({ items, setItems, children }: PropsWithChildren<DndProps>)
function CardOverlay({ id }: { id: SourceID }) { function CardOverlay({ id }: { id: SourceID }) {
return ( return (
<div className={$( <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!`, `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")}> <div className={$("flex justify-between mx-2 items-center")}>
@ -173,45 +136,29 @@ function CardOverlay({ id }: { id: SourceID }) {
) )
} }
const animateLayoutChanges: AnimateLayoutChanges = (args) => { function SortableCardWrapper({ id }: ItemsProps) {
const { isSorting, wasDragging } = args
if (isSorting || wasDragging) {
return defaultAnimateLayoutChanges(args)
}
return true
}
function SortableCardWrapper({ id, ...props }: ItemsProps) {
const { const {
isDragging, isDragging,
attributes,
listeners,
setNodeRef, setNodeRef,
transform, setHandleRef,
transition, OverlayContainer,
} = useSortable({ } = useSortable({ id })
id,
animateLayoutChanges,
transition: {
duration: 300,
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
},
})
const style = { useEffect(() => {
transform: CSS.Transform.toString(transform), if (OverlayContainer) {
transition, OverlayContainer!.className += $(`bg-base`, !isiOS() && "rounded-2xl")
} }
}, [OverlayContainer])
return ( return (
<CardWrapper <>
ref={setNodeRef} <CardWrapper
id={id} ref={setNodeRef}
style={style} id={id}
isDragged={isDragging} isDragging={isDragging}
handleListeners={listeners} setHandleRef={setHandleRef}
{...attributes} />
{...props} {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 { #dropdown-menu li {
--at-apply: hover:bg-neutral-400/10 rounded-md flex items-center p-1 gap-1; --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, retry: 0,
baseURL: "/api", 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)
}