feat: enhance scroll state management (#3962)

* feat: enhance scroll state management

* Drop 古法手写代码,使用 agent 进行优化

* chore: minor update

* update
This commit is contained in:
Konv Suu 2025-06-25 17:11:18 +08:00 committed by GitHub
parent b2570218d5
commit e70c674ae6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 302 additions and 221 deletions

View File

@ -8,7 +8,7 @@ import { ELECTRON_BUILD } from "@follow/shared/constants"
import { springScrollTo } from "@follow/utils/scroller"
import { cn, getOS } from "@follow/utils/utils"
import { m } from "framer-motion"
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
import { NavigationType, Outlet, useLocation, useNavigate, useNavigationType } from "react-router"
@ -34,11 +34,29 @@ function SubviewLayoutInner() {
const [scrollY, setScrollY] = useState(0)
const navigationType = useNavigationType()
const location = useLocation()
const [maxScroll, setMaxScroll] = useState(0)
// Enhanced scroll state management
const isTitleVisible = scrollY > 60
const isHeaderElevated = scrollY > 20
const updateMaxScroll = useCallback(() => {
if (!scrollRef) return
const { scrollHeight, clientHeight } = scrollRef
setMaxScroll(Math.max(0, scrollHeight - clientHeight))
}, [scrollRef])
useEffect(() => {
if (!scrollRef) return
updateMaxScroll()
const resizeObserver = new ResizeObserver(updateMaxScroll)
resizeObserver.observe(scrollRef)
return () => resizeObserver.disconnect()
}, [scrollRef, updateMaxScroll])
useEffect(() => {
// Scroll to top search bar when re-navigating to Discover page while already on it
if (
@ -129,11 +147,11 @@ function SubviewLayoutInner() {
{/* Enhanced Title - absolutely centered */}
<div className="pointer-events-none absolute inset-x-0 flex justify-center">
{isTitleVisible && title && (
{isTitleVisible && title ? (
<div className="text-center">
<div className="text-text truncate font-semibold">{title}</div>
</div>
)}
) : null}
</div>
{/* Action Area - positioned on the right */}
@ -155,12 +173,13 @@ function SubviewLayoutInner() {
ref={setRef}
rootClassName="w-full"
viewportClassName="pb-12 pt-24 [&>div]:items-center"
onUpdateMaxScroll={updateMaxScroll}
>
<Outlet />
</ScrollArea.ScrollArea>
<RootPortal>
<ScrollProgressFAB scrollY={scrollY} scrollRef={scrollRef} />
<ScrollProgressFAB scrollY={scrollY} scrollRef={scrollRef} maxScroll={maxScroll} />
</RootPortal>
</div>
)
@ -171,25 +190,15 @@ const SubViewHeaderRightView = () => {
return <div className="inline-flex items-center">{rightView}</div>
}
const ScrollProgressFAB = ({ scrollY, scrollRef }: { scrollY: number; scrollRef: any }) => {
const [maxScroll, setMaxScroll] = useState(0)
const location = useLocation()
useEffect(() => {
if (!scrollRef) return
const updateMaxScroll = () => {
const { scrollHeight, clientHeight } = scrollRef
setMaxScroll(Math.max(0, scrollHeight - clientHeight))
}
updateMaxScroll()
const resizeObserver = new ResizeObserver(updateMaxScroll)
resizeObserver.observe(scrollRef)
return () => resizeObserver.disconnect()
}, [location.pathname, scrollRef])
const ScrollProgressFAB = ({
scrollY,
scrollRef,
maxScroll,
}: {
scrollY: number
scrollRef: any
maxScroll: number
}) => {
const progress = maxScroll > 0 ? Math.min(100, (scrollY / maxScroll) * 100) : 0
const showProgress = scrollY > 100 && maxScroll > 100

View File

@ -1,9 +1,10 @@
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { views } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { useQuery } from "@tanstack/react-query"
import { useState } from "react"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
@ -53,6 +54,7 @@ export function Trending({
const { t } = useTranslation()
const { t: tCommon } = useTranslation("common")
const lang = useUISettingKey("discoverLanguage")
const { onUpdateMaxScroll } = useScrollElementUpdate()
const [selectedView, setSelectedView] = useState<View>("all")
@ -69,6 +71,12 @@ export function Trending({
},
})
useEffect(() => {
if (!isLoading) {
onUpdateMaxScroll?.()
}
}, [isLoading])
return (
<div className={cn("mx-auto mt-4 w-full max-w-[800px] space-y-6", narrow && "max-w-[400px]")}>
<div

View File

@ -2,11 +2,12 @@ import { EmptyIcon } from "@follow/components/icons/empty.js"
import { Card } from "@follow/components/ui/card/index.jsx"
import { Input } from "@follow/components/ui/input/Input.js"
import { LoadingCircle } from "@follow/components/ui/loading/index.js"
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
import { CategoryMap, RSSHubCategories } from "@follow/constants"
import { cn, formatNumber } from "@follow/utils/utils"
import { keepPreviousData } from "@tanstack/react-query"
import { useMemo, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, useParams } from "react-router"
@ -80,13 +81,17 @@ export const Component = () => {
const [search, setSearch] = useState("")
const items = keys.map((key) => {
return {
key,
data: data![key],
routePrefix: key,
}
})
const items = useMemo(
() =>
keys.map((key) => {
return {
key,
data: data![key],
routePrefix: key,
}
}),
[keys, data],
)
const filteredItems = useMemo(() => {
return items.filter((item) => {
@ -104,6 +109,17 @@ export const Component = () => {
})
}, [items, search])
const { onUpdateMaxScroll } = useScrollElementUpdate()
useEffect(() => {
if (!isLoading && onUpdateMaxScroll) {
// Defer to next tick to avoid blocking main thread
const timeoutId = setTimeout(() => {
onUpdateMaxScroll()
}, 0)
return () => clearTimeout(timeoutId)
}
}, [isLoading])
return (
<div className="w-full max-w-[800px]">
<div className="mb-10 flex w-full items-center justify-center gap-2 text-center text-2xl font-bold">
@ -158,187 +174,218 @@ export const Component = () => {
)
}
const RecommendationListItem = ({
data,
routePrefix,
rsshubAnalyticsData,
}: {
data: RouteData[string]
routePrefix: string
rsshubAnalyticsData: Awaited<
ReturnType<(typeof apiClient)["discover"]["rsshub-analytics"]["$get"]>
>["data"]
}) => {
const { t } = useTranslation()
const { present } = useModalStack()
const RecommendationListItem = memo(
({
data,
routePrefix,
rsshubAnalyticsData,
}: {
data: RouteData[string]
routePrefix: string
rsshubAnalyticsData: Awaited<
ReturnType<(typeof apiClient)["discover"]["rsshub-analytics"]["$get"]>
>["data"]
}) => {
const { t } = useTranslation()
const { present } = useModalStack()
const { maintainers, categories, routes } = useMemo(() => {
const maintainers = new Set<string>()
const categories = new Set<string>()
const routes = Object.keys(data.routes).sort((a, b) => {
const aHeat = rsshubAnalyticsData?.[`/${routePrefix}${a}`]?.subscriptionCount ?? 0
const bHeat = rsshubAnalyticsData?.[`/${routePrefix}${b}`]?.subscriptionCount ?? 0
return bHeat - aHeat
})
const { maintainers, categories, routes } = useMemo(() => {
const maintainers = new Set<string>()
const categories = new Set<string>()
const routes = Object.keys(data.routes).sort((a, b) => {
const aHeat = rsshubAnalyticsData?.[`/${routePrefix}${a}`]?.subscriptionCount ?? 0
const bHeat = rsshubAnalyticsData?.[`/${routePrefix}${b}`]?.subscriptionCount ?? 0
return bHeat - aHeat
})
for (const route in data.routes) {
const routeData = data.routes[route]!
if (routeData.maintainers) {
routeData.maintainers.forEach((m) => maintainers.add(m))
for (const route in data.routes) {
const routeData = data.routes[route]!
if (routeData.maintainers) {
routeData.maintainers.forEach((m) => maintainers.add(m))
}
if (routeData.categories) {
routeData.categories.forEach((c) => categories.add(c))
}
}
if (routeData.categories) {
routeData.categories.forEach((c) => categories.add(c))
categories.delete("popular")
return {
maintainers: Array.from(maintainers),
categories: Array.from(categories) as unknown as typeof RSSHubCategories,
routes,
}
}
categories.delete("popular")
return {
maintainers: Array.from(maintainers),
categories: Array.from(categories) as unknown as typeof RSSHubCategories,
routes,
}
}, [data, rsshubAnalyticsData, routePrefix])
}, [data, rsshubAnalyticsData, routePrefix])
const follow = useFollow()
const follow = useFollow()
return (
<Card className="shadow-background border-border overflow-hidden rounded-lg border transition-shadow duration-200 hover:shadow-md">
<div className="border-border flex items-center gap-3 border-b p-4">
<div className="bg-background size-8 overflow-hidden rounded-full">
<FeedIcon className="mr-0 size-8" size={32} siteUrl={`https://${data.url}`} />
</div>
<div className="flex w-full flex-1 justify-between">
<h3 className="line-clamp-1 text-base font-medium">
<a
href={`https://${data.url}`}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{data.name}
</a>
</h3>
const handleRouteClick = useCallback(
(route: string) => {
present({
id: `recommendation-content-${route}`,
content: () => (
<RecommendationContent routePrefix={routePrefix} route={data.routes[route]!} />
),
icon: <FeedIcon className="size-4" size={16} siteUrl={`https://${data.url}`} />,
title: `${data.name} - ${data.routes[route]!.name}`,
})
},
[present, routePrefix, data, data.url, data.name],
)
<div className="flex flex-wrap gap-1.5 text-xs">
{categories.map((c) => (
<Link
to={`/discover/category/${c}`}
key={c}
className={cn(
"bg-accent/10 cursor-pointer rounded-full px-2 py-0.5 leading-5 duration-200",
!RSSHubCategories.includes(c) ? "pointer-events-none opacity-50" : "",
)}
>
{RSSHubCategories.includes(c)
? t(`discover.category.${c}`, { ns: "common" })
: c.charAt(0).toUpperCase() + c.slice(1)}
</Link>
))}
const handleFeedClick = useCallback(
(feedId: string) => {
follow({
isList: false,
id: feedId,
})
},
[follow],
)
return (
<Card className="shadow-background border-border overflow-hidden rounded-lg border transition-shadow duration-200 hover:shadow-md">
<div className="border-border flex items-center gap-3 border-b p-4">
<div className="bg-background size-8 overflow-hidden rounded-full">
<FeedIcon className="mr-0 size-8" size={32} siteUrl={`https://${data.url}`} />
</div>
</div>
</div>
<div className="p-4 pt-2">
<ul className="text-text mb-3">
{routes.map((route) => {
const routeData = data.routes[route]!
if (Array.isArray(routeData.path)) {
routeData.path = routeData.path.find((p) => p === route) ?? routeData.path[0]
}
const analytics = rsshubAnalyticsData?.[`/${routePrefix}${routeData.path}`]
return (
<li
key={route}
className="hover:bg-material-opaque -mx-4 rounded p-3 px-5 transition-colors"
role="button"
onClick={() => {
present({
id: `recommendation-content-${route}`,
content: () => (
<RecommendationContent
routePrefix={routePrefix}
route={data.routes[route]!}
/>
),
icon: <FeedIcon className="size-4" size={16} siteUrl={`https://${data.url}`} />,
title: `${data.name} - ${data.routes[route]!.name}`,
})
}}
<div className="flex w-full flex-1 justify-between">
<h3 className="line-clamp-1 text-base font-medium">
<a
href={`https://${data.url}`}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
<div className="w-full">
<div className="flex w-full items-center gap-8">
<div className="flex flex-1 items-center gap-2">
<div className="bg-accent mr-2 size-1.5 rounded-full" />
<div className="relative h-5 grow">
<div className="text-title3 absolute inset-0 flex items-center gap-3 font-medium">
<EllipsisHorizontalTextWithTooltip>
{routeData.name}
</EllipsisHorizontalTextWithTooltip>
<EllipsisHorizontalTextWithTooltip className="text-text-secondary text-xs">{`rsshub://${routePrefix}${routeData.path}`}</EllipsisHorizontalTextWithTooltip>
</div>
</div>
</div>
{!!analytics?.subscriptionCount && (
<div className="flex items-center gap-0.5 text-xs">
<i className="i-mgc-fire-cute-re" />
{formatNumber(analytics?.subscriptionCount || 0)}
</div>
)}
</div>
{analytics?.topFeeds && (
<div className="mt-2 flex items-center gap-10 pl-5 text-xs">
{analytics.topFeeds.slice(0, 2).map((feed) => (
<div key={feed.id} className="flex w-2/5 flex-1 items-center text-sm">
<FeedIcon
feed={feed}
className="mask-squircle mask shrink-0 rounded-none"
size={16}
/>
<div
className="min-w-0 leading-tight"
onClick={(e) => {
e.stopPropagation()
follow({
isList: false,
id: feed.id,
})
}}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
{getPreferredTitle(feed) || feed?.title}
</EllipsisHorizontalTextWithTooltip>
</div>
</div>
))}
</div>
{data.name}
</a>
</h3>
<div className="flex flex-wrap gap-1.5 text-xs">
{categories.map((c) => (
<Link
to={`/discover/category/${c}`}
key={c}
className={cn(
"bg-accent/10 cursor-pointer rounded-full px-2 py-0.5 leading-5 duration-200",
!RSSHubCategories.includes(c) ? "pointer-events-none opacity-50" : "",
)}
</div>
</li>
)
})}
</ul>
{maintainers.length > 0 && (
<div className="text-text-secondary mt-2 flex items-center text-xs">
<i className="i-mgc-hammer-cute-re mr-1 shrink-0 translate-y-0.5 self-start" />
<span>
{maintainers.map((m, i) => (
<span key={m}>
<a
href={`https://github.com/${m}`}
className="hover:underline"
target="_blank"
rel="noreferrer"
>
@{m}
</a>
{i < maintainers.length - 1 ? ", " : ""}
</span>
>
{RSSHubCategories.includes(c)
? t(`discover.category.${c}`, { ns: "common" })
: c.charAt(0).toUpperCase() + c.slice(1)}
</Link>
))}
</span>
</div>
</div>
)}
</div>
</Card>
)
}
</div>
<div className="p-4 pt-2">
<ul className="text-text mb-3">
{routes.map((route) => (
<RouteItem
key={route}
route={route}
routeData={data.routes[route]!}
routePrefix={routePrefix}
rsshubAnalyticsData={rsshubAnalyticsData}
onRouteClick={handleRouteClick}
onFeedClick={handleFeedClick}
/>
))}
</ul>
{maintainers.length > 0 && (
<div className="text-text-secondary mt-2 flex items-center text-xs">
<i className="i-mgc-hammer-cute-re mr-1 shrink-0 translate-y-0.5 self-start" />
<span>
{maintainers.map((m, i) => (
<span key={m}>
<a
href={`https://github.com/${m}`}
className="hover:underline"
target="_blank"
rel="noreferrer"
>
@{m}
</a>
{i < maintainers.length - 1 ? ", " : ""}
</span>
))}
</span>
</div>
)}
</div>
</Card>
)
},
)
const RouteItem = memo(
({
route,
routeData,
routePrefix,
rsshubAnalyticsData,
onRouteClick,
onFeedClick,
}: {
route: string
routeData: any
routePrefix: string
rsshubAnalyticsData: any
onRouteClick: (route: string) => void
onFeedClick: (feedId: string) => void
}) => {
if (Array.isArray(routeData.path)) {
routeData.path = routeData.path.find((p: string) => p === route) ?? routeData.path[0]
}
const analytics = rsshubAnalyticsData?.[`/${routePrefix}${routeData.path}`]
return (
<li
className="hover:bg-material-opaque -mx-4 rounded p-3 px-5 transition-colors"
role="button"
onClick={() => onRouteClick(route)}
>
<div className="w-full">
<div className="flex w-full items-center gap-8">
<div className="flex flex-1 items-center gap-2">
<div className="bg-accent mr-2 size-1.5 rounded-full" />
<div className="relative h-5 grow">
<div className="text-title3 absolute inset-0 flex items-center gap-3 font-medium">
<EllipsisHorizontalTextWithTooltip>
{routeData.name}
</EllipsisHorizontalTextWithTooltip>
<EllipsisHorizontalTextWithTooltip className="text-text-secondary text-xs">{`rsshub://${routePrefix}${routeData.path}`}</EllipsisHorizontalTextWithTooltip>
</div>
</div>
</div>
{!!analytics?.subscriptionCount && (
<div className="flex items-center gap-0.5 text-xs">
<i className="i-mgc-fire-cute-re" />
{formatNumber(analytics?.subscriptionCount || 0)}
</div>
)}
</div>
{analytics?.topFeeds && (
<div className="mt-2 flex items-center gap-10 pl-5 text-xs">
{analytics.topFeeds.slice(0, 2).map((feed: any) => (
<div key={feed.id} className="flex w-2/5 flex-1 items-center text-sm">
<FeedIcon
feed={feed}
className="mask-squircle mask shrink-0 rounded-none"
size={16}
/>
<div className="min-w-0 leading-tight" onClick={() => onFeedClick(feed.id)}>
<EllipsisHorizontalTextWithTooltip className="truncate">
{getPreferredTitle(feed) || feed?.title}
</EllipsisHorizontalTextWithTooltip>
</div>
</div>
))}
</div>
)}
</div>
</li>
)
},
)

View File

@ -1,4 +1,5 @@
import { Divider } from "@follow/components/ui/divider/Divider.js"
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx"
import { UserRole } from "@follow/constants"
@ -63,6 +64,7 @@ export function Component() {
const presentActivationModal = useActivationModal()
const role = useUserRole()
const { onUpdateMaxScroll } = useScrollElementUpdate()
const currentTabs = tabs.map((tab) => {
const disabled = tab.disableForTrial && role === UserRole.Trial
@ -105,6 +107,8 @@ export function Component() {
onClick={() => {
if (tab.disabled) {
presentActivationModal()
} else {
onUpdateMaxScroll?.()
}
}}
>

View File

@ -3,7 +3,7 @@ import { cn } from "@follow/utils/utils"
import * as ScrollAreaBase from "@radix-ui/react-scroll-area"
import * as React from "react"
import { ScrollElementContext } from "./ctx"
import { ScrollElementContext, ScrollElementEventsContext } from "./ctx"
import styles from "./index.module.css"
const Corner = ({
@ -144,7 +144,7 @@ export const ScrollArea = ({
onScroll,
orientation = "vertical",
asChild = false,
onUpdateMaxScroll,
focusable = true,
}: React.PropsWithChildren & {
rootClassName?: string
@ -153,6 +153,7 @@ export const ScrollArea = ({
flex?: boolean
mask?: boolean
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
onUpdateMaxScroll?: () => void
orientation?: "vertical" | "horizontal"
asChild?: boolean
focusable?: boolean
@ -160,22 +161,26 @@ export const ScrollArea = ({
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(null)
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
const events = React.useMemo(() => ({ onUpdateMaxScroll }), [onUpdateMaxScroll])
return (
<ScrollElementContext value={viewportRef}>
<Root className={rootClassName}>
<Viewport
ref={setViewportRef}
onWheel={stopPropagation}
className={cn(flex ? "[&>div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
mask={mask}
asChild={asChild}
onScroll={onScroll}
focusable={focusable}
>
{children}
</Viewport>
<Scrollbar orientation={orientation} className={scrollbarClassName} />
</Root>
<ScrollElementEventsContext value={events}>
<Root className={rootClassName}>
<Viewport
ref={setViewportRef}
onWheel={stopPropagation}
className={cn(flex ? "[&>div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
mask={mask}
asChild={asChild}
onScroll={onScroll}
focusable={focusable}
>
{children}
</Viewport>
<Scrollbar orientation={orientation} className={scrollbarClassName} />
</Root>
</ScrollElementEventsContext>
</ScrollElementContext>
)
}

View File

@ -1,3 +1,9 @@
import { createContext } from "react"
export const ScrollElementContext = createContext<HTMLElement | null>(document.documentElement)
export const ScrollElementEventsContext = createContext<{
onUpdateMaxScroll?: () => void
}>({
onUpdateMaxScroll: undefined,
})

View File

@ -3,7 +3,7 @@ import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
import { use, useCallback, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { ScrollElementContext } from "./ctx"
import { ScrollElementContext, ScrollElementEventsContext } from "./ctx"
const THRESHOLD = 0
export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
@ -101,3 +101,5 @@ export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
* @returns
*/
export const useScrollViewElement = () => use(ScrollElementContext)
export const useScrollElementUpdate = () => use(ScrollElementEventsContext)