feat(desktop): enhance entry detail navigation tips and animations

- Added a new "smooth-bounce" animation to improve visual feedback when scrolling.
- Integrated AnimatePresence for smoother transitions in the SubscriptionColumn and AIEntryLayout components.
- Updated text prompts for returning to the timeline, enhancing clarity and user experience.
- Improved multi-language support for new exit hints in English, Japanese, and Chinese.

These changes collectively enhance the user experience by providing clearer navigation cues and smoother animations.
This commit is contained in:
DIYgod 2025-08-21 20:10:58 +08:00
parent cf8865342a
commit c2ea3828fe
No known key found for this signature in database
6 changed files with 68 additions and 54 deletions

View File

@ -8,6 +8,7 @@ import { defaultUISettings } from "@follow/shared/settings/defaults"
import { cn } from "@follow/utils"
import { Slot } from "@radix-ui/react-slot"
import { debounce } from "es-toolkit/compat"
import { AnimatePresence } from "motion/react"
import type { PropsWithChildren } from "react"
import * as React from "react"
import { useEffect, useRef, useState } from "react"
@ -21,6 +22,7 @@ import {
useTimelineColumnShow,
useTimelineColumnTempShow,
} from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { FloatingLayerScope } from "~/constants"
import { useFeature } from "~/hooks/biz/useFeature"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
@ -30,6 +32,7 @@ import { useI18n } from "~/hooks/common"
import { NetworkStatusIndicator } from "~/modules/app/NetworkStatusIndicator"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandBinding } from "~/modules/command/hooks/use-command-binding"
import { useEntryContentScrollToTop } from "~/modules/entry-content/atoms"
import { CornerPlayer } from "~/modules/player/corner-player"
import { SubscriptionColumn } from "~/modules/subscription-column"
import { getSelectedFeedIds, resetSelectedFeedIds } from "~/modules/subscription-column/atom"
@ -172,6 +175,7 @@ const FeedResponsiveResizerContainer = ({
timer = clearTimeout(timer)
}
}, [feedColumnShow])
const isAtTop = !!useEntryContentScrollToTop()
return (
<>
@ -193,22 +197,25 @@ const FeedResponsiveResizerContainer = ({
<Slot className={!feedColumnShow ? "!bg-sidebar" : ""}>{children}</Slot>
{/* Semi-transparent overlay with exit hint when in wide mode with entry selected */}
{entryId && !isPendingEntry && aiEnabled && (
<div
className="absolute inset-0 z-20 cursor-pointer bg-white/50 backdrop-blur-[2px] transition-colors duration-200 hover:bg-white/70"
onClick={() => navigate({ entryId: null })}
>
<div className="flex items-center justify-center px-4 pt-16">
<div className="flex flex-col items-center gap-2 rounded-lg px-4 py-3">
<AnimatePresence>
{entryId && !isPendingEntry && aiEnabled && !isAtTop && (
<m.div
className="bg-background/80 hover:bg-background/90 center absolute inset-0 z-20 cursor-pointer backdrop-blur-[2px] transition-colors duration-200"
onClick={() => navigate({ entryId: null })}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="flex flex-col items-center gap-2 rounded-lg">
<div className="text-text flex items-center gap-2">
<i className="i-mgc-arrow-left-cute-re text-lg" />
<i className="i-mgc-up-cute-re text-lg" />
<span className="text-sm font-medium">{t("entry.exit_detail")}</span>
</div>
<span className="text-text-secondary text-xs">{t("entry.click_to_return")}</span>
</div>
</div>
</div>
)}
</m.div>
)}
</AnimatePresence>
</div>
<div

View File

@ -4,7 +4,8 @@ import { PanelSplitter } from "@follow/components/ui/divider/index.js"
import { defaultUISettings } from "@follow/shared/settings/defaults"
import { cn } from "@follow/utils"
import { AnimatePresence } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { useTranslation } from "react-i18next"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
@ -18,10 +19,12 @@ import { EntryContent } from "~/modules/entry-content/components/entry-content"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { AIChatRoot } from "../ai-chat/components/layouts/AIChatRoot"
import { useEntryContentScrollToTop } from "../entry-content/atoms"
import { EntryColumn } from "./index"
const AIEntryLayoutImpl = () => {
const { entryId } = useParams()
const { t } = useTranslation()
const navigate = useNavigateEntry()
const panelStyle = useAIChatPanelStyle()
@ -31,7 +34,6 @@ const AIEntryLayoutImpl = () => {
const entryContentRef = useRef<HTMLDivElement>(null)
const accumulatedDelta = useRef(0)
const isScrollingAtTop = useRef(false)
const [showScrollHint, setShowScrollHint] = useState(false)
const handleCloseGesture = useCallback(() => {
navigate({ entryId: null })
@ -50,7 +52,6 @@ const AIEntryLayoutImpl = () => {
// Check if we're at the top of the content
const scrollTop = scrollElement?.scrollTop || 0
isScrollingAtTop.current = scrollTop === 0
setShowScrollHint(scrollTop === 0)
// Handle trackpad/mouse wheel: upward scroll (deltaY < 0) or downward swipe gesture
// On macOS trackpad, natural scrolling makes upward finger movement negative deltaY
@ -90,24 +91,10 @@ const AIEntryLayoutImpl = () => {
el.addEventListener("wheel", handleWheel, { passive: false })
})
// Initial scroll position check for hint visibility
const initialCheckScrollPosition = () => {
const scrollTop = scrollViewport?.scrollTop || element.scrollTop || 0
setShowScrollHint(scrollTop === 0)
}
// Check initial position
initialCheckScrollPosition()
// Add scroll listener for hint visibility
const scrollElement = scrollViewport || element
scrollElement.addEventListener("scroll", initialCheckScrollPosition, { passive: true })
return () => {
elementsToListen.forEach((el) => {
el.removeEventListener("wheel", handleWheel)
})
scrollElement.removeEventListener("scroll", initialCheckScrollPosition)
}
}, [realEntryId, handleWheel])
@ -130,6 +117,7 @@ const AIEntryLayoutImpl = () => {
window.dispatchEvent(new Event("resize"))
},
})
const isAtTop = !!useEntryContentScrollToTop()
return (
<div className="relative flex min-w-0 grow">
@ -152,26 +140,31 @@ const AIEntryLayoutImpl = () => {
transition={Spring.presets.microRebound}
className="bg-theme-background absolute inset-0 z-10 border-l"
>
{/* Scroll hint indicator */}
<div className="center z-50 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ entryId: null })}
buttonClassName="transform cursor-pointer select-none no-drag-region"
aria-label="Scroll up or click to exit"
>
<div className="text-text flex items-center gap-2 rounded-full font-medium">
<i
className={cn(
"text-base",
showScrollHint ? "i-mgc-up-cute-re" : "i-mgc-close-cute-re",
)}
/>
<span>{showScrollHint ? "Scroll up to exit" : "Click to exit"}</span>
</div>
</Button>
</div>
<AnimatePresence>
{/* Scroll hint indicator */}
{isAtTop && (
<m.div
className="center z-50"
initial={{ y: -30, height: 0 }}
animate={{ y: 0, height: "auto" }}
exit={{ y: -30, height: 0 }}
>
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ entryId: null })}
buttonClassName="transform cursor-pointer select-none no-drag-region w-full py-3 rounded-none"
aria-label="Scroll up or click to exit"
>
<div className="text-text flex items-center gap-2 rounded-full font-medium">
<i className="i-mgc-up-cute-re repeat-[2] animate-smooth-bounce text-base" />
<span>{t("entry.scroll_up_to_exit")}</span>
</div>
</Button>
</m.div>
)}
</AnimatePresence>
<EntryContent entryId={realEntryId} className="h-[calc(100%-2.25rem)]" />
</m.div>
)}

View File

@ -84,6 +84,16 @@ export default extendConfig({
backgroundPosition: "-200% 0",
},
},
"smooth-bounce": {
"50%": {
transform: "translateY(25%)",
"animation-timing-function": "cubic-bezier(0.8, 0, 1, 1)",
},
"0%, 100%": {
transform: "none",
"animation-timing-function": "cubic-bezier(0, 0, 0.2, 1)",
},
},
},
animation: {
"caret-blink": "caret-blink 1.25s ease-out infinite",
@ -92,6 +102,7 @@ export default extendConfig({
"gradient-x": "gradient-x 3s linear infinite",
glow: "glow 1.5s ease-in-out infinite",
shimmer: "shimmer 2s linear infinite",
"smooth-bounce": "smooth-bounce 1s infinite",
},
},
},

View File

@ -111,8 +111,9 @@
"discover.target.feeds": "Feeds",
"discover.target.label": "Search for",
"discover.target.lists": "Lists",
"entry.click_to_return": "Click to return to entry list",
"entry.exit_detail": "Exit Detail View",
"entry.click_to_return": "Scroll up or click here to return",
"entry.exit_detail": "Return to timeline",
"entry.scroll_up_to_exit": "Scroll up to return to timeline",
"entry_actions.copied_notify": "{{which}} copied to clipboard.",
"entry_actions.copy_link": "Copy Link",
"entry_actions.copy_title": "Copy Title",

View File

@ -111,8 +111,9 @@
"discover.target.feeds": "フィード",
"discover.target.label": "検索対象",
"discover.target.lists": "リスト",
"entry.click_to_return": "クリックしてエントリリストに戻る",
"entry.exit_detail": "詳細を終了",
"entry.click_to_return": "上にスクロールまたはこの領域をクリックして戻る",
"entry.exit_detail": "時間軸に戻る",
"entry.scroll_up_to_exit": "上にスクロールして時間軸に戻る",
"entry_actions.copied_notify": "{{which}} をクリップボードにコピーしました。",
"entry_actions.copy_link": "リンクをコピー",
"entry_actions.copy_title": "タイトルをコピー",

View File

@ -111,8 +111,9 @@
"discover.target.feeds": "订阅源",
"discover.target.label": "搜索",
"discover.target.lists": "列表",
"entry.click_to_return": "点击返回条目列表",
"entry.exit_detail": "退出详情",
"entry.click_to_return": "向上滚动或点击此区域返回",
"entry.exit_detail": "返回时间线",
"entry.scroll_up_to_exit": "向上滚动返回时间线",
"entry_actions.copied_notify": "{{which}}已复制到剪贴板。",
"entry_actions.copy_link": "复制链接",
"entry_actions.copy_title": "复制标题",