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:
parent
cf8865342a
commit
c2ea3828fe
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "タイトルをコピー",
|
||||
|
|
|
|||
|
|
@ -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": "复制标题",
|
||||
|
|
|
|||
Loading…
Reference in New Issue