继续观看列表使用虚拟滚动
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-window": "^2.2.3",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/react-window": "^2.0.0",
|
||||||
"@types/testing-library__jest-dom": "^5.14.9",
|
"@types/testing-library__jest-dom": "^5.14.9",
|
||||||
"@types/xml2js": "^0.4.14",
|
"@types/xml2js": "^0.4.14",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
|
|||||||
Generated
+40
@@ -104,6 +104,9 @@ importers:
|
|||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0(@types/[email protected])([email protected])
|
version: 10.1.0(@types/[email protected])([email protected])
|
||||||
|
react-window:
|
||||||
|
specifier: ^2.2.3
|
||||||
|
version: 2.2.3([email protected]([email protected]))([email protected])
|
||||||
redis:
|
redis:
|
||||||
specifier: ^4.6.7
|
specifier: ^4.6.7
|
||||||
version: 4.7.1
|
version: 4.7.1
|
||||||
@@ -171,6 +174,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.1.6
|
specifier: ^19.1.6
|
||||||
version: 19.1.6(@types/[email protected])
|
version: 19.1.6(@types/[email protected])
|
||||||
|
'@types/react-window':
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0([email protected]([email protected]))([email protected])
|
||||||
'@types/testing-library__jest-dom':
|
'@types/testing-library__jest-dom':
|
||||||
specifier: ^5.14.9
|
specifier: ^5.14.9
|
||||||
version: 5.14.9
|
version: 5.14.9
|
||||||
@@ -1197,24 +1203,28 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
|
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
|
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
|
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@next/[email protected]':
|
'@next/[email protected]':
|
||||||
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
|
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
|
||||||
@@ -1630,6 +1640,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.0.0
|
'@types/react': ^19.0.0
|
||||||
|
|
||||||
|
'@types/[email protected]':
|
||||||
|
resolution: {integrity: sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ==}
|
||||||
|
deprecated: This is a stub types definition. react-window provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
|
resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
|
||||||
|
|
||||||
@@ -1769,41 +1783,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==}
|
resolution: {integrity: sha512-vdqBh911wc5awE2bX2zx3eflbyv8U9xbE/jVKAm425eRoOVv/VseGZsqi3A3SykckSpF4wSROkbQPvbQFn8EsA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==}
|
resolution: {integrity: sha512-/8JFZ/SnuDr1lLEVsxsuVwrsGquTvT51RZGvyDB/dOK3oYK2UqeXzgeyq6Otp8FZXQcEYqJwxb9v+gtdXn03eQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==}
|
resolution: {integrity: sha512-FkJjybtrl+rajTw4loI3L6YqSOpeZfDls4SstL/5lsP2bka9TiHUjgMBjygeZEis1oC8LfJTS8FSgpKPaQx2tQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==}
|
resolution: {integrity: sha512-w/NZfHNeDusbqSZ8r/hp8iL4S39h4+vQMc9/vvzuIKMWKppyUGKm3IST0Qv0aOZ1rzIbl9SrDeIqK86ZpUK37w==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==}
|
resolution: {integrity: sha512-bEPBosut8/8KQbUixPry8zg/fOzVOWyvwzOfz0C0Rw6dp+wIBseyiHKjkcSyZKv/98edrbMknBaMNJfA/UEdqw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==}
|
resolution: {integrity: sha512-LDtMT7moE3gK753gG4pc31AAqGUC86j3AplaFusc717EUGF9ZFJ356sdQzzZzkBk1XzMdxFyZ4f/i35NKM/lFA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==}
|
resolution: {integrity: sha512-WmFd5KINHIXj8o1mPaT8QRjA9HgSXhN1gl9Da4IZihARihEnOylu4co7i/yeaIpcfsI6sYs33cNZKyHYDh0lrA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==}
|
resolution: {integrity: sha512-CYuXbANW+WgzVRIl8/QvZmDaZxrqvOldOwlbUjIM4pQ46FJ0W5cinJ/Ghwa/Ng1ZPMJMk1VFdsD/XwmCGIXBWg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@unrs/[email protected]':
|
'@unrs/[email protected]':
|
||||||
resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==}
|
resolution: {integrity: sha512-6Rp2WH0OoitMYR57Z6VE8Y6corX8C6QEMWLgOV6qXiJIeZ1F9WGXY/yQ8yDC4iTraotyLOeJ2Asea0urWj2fKQ==}
|
||||||
@@ -4756,6 +4778,12 @@ packages:
|
|||||||
'@types/react': '>=18'
|
'@types/react': '>=18'
|
||||||
react: '>=18'
|
react: '>=18'
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -7623,6 +7651,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.23
|
'@types/react': 18.3.23
|
||||||
|
|
||||||
|
'@types/[email protected]([email protected]([email protected]))([email protected])':
|
||||||
|
dependencies:
|
||||||
|
react-window: 2.2.3([email protected]([email protected]))([email protected])
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
|
||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
@@ -11429,6 +11464,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
[email protected]([email protected]([email protected]))([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1([email protected])
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
|
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
import ScrollableRow from '@/components/ScrollableRow';
|
import VirtualScrollableRow from '@/components/VirtualScrollableRow';
|
||||||
|
|
||||||
interface ContinueWatchingProps {
|
interface ContinueWatchingProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -103,56 +103,60 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollableRow>
|
{loading ? (
|
||||||
{loading
|
// 加载状态显示灰色占位数据(使用原始 ScrollableRow)
|
||||||
? // 加载状态显示灰色占位数据
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
||||||
Array.from({ length: 8 }).map((_, index) => (
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||||
|
>
|
||||||
|
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||||
|
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 h-1 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||||
|
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800 w-3/4'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 使用虚拟滚动显示真实数据
|
||||||
|
<VirtualScrollableRow>
|
||||||
|
{playRecords.map((record) => {
|
||||||
|
const { source, id } = parseKey(record.key);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={record.key}
|
||||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||||
>
|
>
|
||||||
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
<VideoCard
|
||||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
id={id}
|
||||||
</div>
|
title={record.title}
|
||||||
<div className='mt-1 h-1 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
poster={record.cover}
|
||||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800 w-3/4'></div>
|
year={record.year}
|
||||||
|
source={source}
|
||||||
|
source_name={record.source_name}
|
||||||
|
progress={getProgress(record)}
|
||||||
|
episodes={record.total_episodes}
|
||||||
|
currentEpisode={record.index}
|
||||||
|
query={record.search_title}
|
||||||
|
from='playrecord'
|
||||||
|
onDelete={() =>
|
||||||
|
setPlayRecords((prev) =>
|
||||||
|
prev.filter((r) => r.key !== record.key)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||||
|
origin={record.origin}
|
||||||
|
orientation='horizontal'
|
||||||
|
playTime={record.play_time}
|
||||||
|
totalTime={record.total_time}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
: // 显示真实数据
|
})}
|
||||||
playRecords.map((record) => {
|
</VirtualScrollableRow>
|
||||||
const { source, id } = parseKey(record.key);
|
)}
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={record.key}
|
|
||||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
|
||||||
>
|
|
||||||
<VideoCard
|
|
||||||
id={id}
|
|
||||||
title={record.title}
|
|
||||||
poster={record.cover}
|
|
||||||
year={record.year}
|
|
||||||
source={source}
|
|
||||||
source_name={record.source_name}
|
|
||||||
progress={getProgress(record)}
|
|
||||||
episodes={record.total_episodes}
|
|
||||||
currentEpisode={record.index}
|
|
||||||
query={record.search_title}
|
|
||||||
from='playrecord'
|
|
||||||
onDelete={() =>
|
|
||||||
setPlayRecords((prev) =>
|
|
||||||
prev.filter((r) => r.key !== record.key)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
|
||||||
origin={record.origin}
|
|
||||||
orientation='horizontal'
|
|
||||||
playTime={record.play_time}
|
|
||||||
totalTime={record.total_time}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollableRow>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface VirtualScrollableRowProps {
|
||||||
|
children: React.ReactNode[];
|
||||||
|
maxVisible?: number; // 最大可见数量
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualScrollableRow({
|
||||||
|
children,
|
||||||
|
maxVisible = 30, // 默认最多显示 30 个项目
|
||||||
|
}: VirtualScrollableRowProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showLeftScroll, setShowLeftScroll] = useState(false);
|
||||||
|
const [showRightScroll, setShowRightScroll] = useState(false);
|
||||||
|
const [visibleRange, setVisibleRange] = useState({ start: 0, end: maxVisible });
|
||||||
|
|
||||||
|
// 检查滚动状态
|
||||||
|
const checkScroll = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = containerRef.current;
|
||||||
|
const canScrollLeft = scrollLeft > 0;
|
||||||
|
const canScrollRight = scrollLeft + clientWidth < scrollWidth - 10;
|
||||||
|
|
||||||
|
setShowLeftScroll(canScrollLeft);
|
||||||
|
setShowRightScroll(canScrollRight);
|
||||||
|
|
||||||
|
// 计算可见范围(基于滚动位置)
|
||||||
|
const itemWidth = 208; // 每个项目约 200px + 8px gap
|
||||||
|
const scrolledItems = Math.floor(scrollLeft / itemWidth);
|
||||||
|
const visibleItems = Math.ceil(clientWidth / itemWidth);
|
||||||
|
|
||||||
|
// 扩展渲染范围(当前可见 + 前后缓冲)
|
||||||
|
const bufferSize = 5;
|
||||||
|
const newStart = Math.max(0, scrolledItems - bufferSize);
|
||||||
|
const newEnd = Math.min(children.length, scrolledItems + visibleItems + bufferSize);
|
||||||
|
|
||||||
|
setVisibleRange({ start: newStart, end: newEnd });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkScroll();
|
||||||
|
const container = containerRef.current;
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('scroll', checkScroll);
|
||||||
|
return () => container.removeEventListener('scroll', checkScroll);
|
||||||
|
}
|
||||||
|
}, [children.length]);
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', checkScroll);
|
||||||
|
return () => window.removeEventListener('resize', checkScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollLeft = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollBy({
|
||||||
|
left: -400,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollRight = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollBy({
|
||||||
|
left: 400,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染可见项目
|
||||||
|
const visibleChildren = children.slice(visibleRange.start, visibleRange.end);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group">
|
||||||
|
{/* 左侧滚动按钮 */}
|
||||||
|
{showLeftScroll && (
|
||||||
|
<button
|
||||||
|
onClick={scrollLeft}
|
||||||
|
className="absolute left-0 top-1/2 -translate-y-1/2 z-[600] bg-white/90 dark:bg-gray-800/90 p-2 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-700"
|
||||||
|
aria-label="向左滚动"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-6 h-6 text-gray-700 dark:text-gray-200" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 滚动容器 */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex gap-2 overflow-x-auto scrollbar-hide scroll-smooth"
|
||||||
|
style={{ scrollBehavior: 'smooth' }}
|
||||||
|
>
|
||||||
|
{/* 左侧占位符(用于保持滚动位置) */}
|
||||||
|
{visibleRange.start > 0 && (
|
||||||
|
<div style={{ minWidth: visibleRange.start * 208, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 渲染可见项目 */}
|
||||||
|
{visibleChildren}
|
||||||
|
|
||||||
|
{/* 右侧占位符 */}
|
||||||
|
{visibleRange.end < children.length && (
|
||||||
|
<div style={{ minWidth: (children.length - visibleRange.end) * 208, flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧滚动按钮 */}
|
||||||
|
{showRightScroll && (
|
||||||
|
<button
|
||||||
|
onClick={scrollRight}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 z-[600] bg-white/90 dark:bg-gray-800/90 p-2 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-700"
|
||||||
|
aria-label="向右滚动"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-6 h-6 text-gray-700 dark:text-gray-200" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user