feat: revert entry animation and entry header
This commit is contained in:
parent
f5d7cbceb5
commit
f3a4eab8af
|
|
@ -1,9 +1,10 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
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 { useMemo, useRef } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useResizable } from "react-resizable-layout"
|
||||
import { useParams } from "react-router"
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
|||
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
|
||||
import { EntryContent } from "~/modules/entry-content/components/entry-content"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
|
@ -20,11 +22,95 @@ import { EntryColumn } from "./index"
|
|||
|
||||
const AIEntryLayoutImpl = () => {
|
||||
const { entryId } = useParams()
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
|
||||
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
|
||||
|
||||
// Swipe/scroll to close functionality
|
||||
const entryContentRef = useRef<HTMLDivElement>(null)
|
||||
const accumulatedDelta = useRef(0)
|
||||
const isScrollingAtTop = useRef(false)
|
||||
const [showScrollHint, setShowScrollHint] = useState(false)
|
||||
|
||||
const handleCloseGesture = useCallback(() => {
|
||||
navigate({ entryId: null })
|
||||
}, [navigate])
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (!realEntryId || !entryContentRef.current) return
|
||||
|
||||
// Find the actual scroll viewport element with correct Radix UI attribute
|
||||
const entryContentElement = entryContentRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
) as HTMLElement
|
||||
const scrollElement = entryContentElement || entryContentRef.current
|
||||
|
||||
// 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
|
||||
if (e.deltaY < 0 && isScrollingAtTop.current) {
|
||||
e.preventDefault()
|
||||
accumulatedDelta.current += Math.abs(e.deltaY)
|
||||
|
||||
// Close when accumulated scroll exceeds threshold (150px for trackpad sensitivity)
|
||||
if (accumulatedDelta.current > 1000) {
|
||||
handleCloseGesture()
|
||||
accumulatedDelta.current = 0
|
||||
}
|
||||
} else {
|
||||
// Reset accumulation when scrolling down or not at top
|
||||
accumulatedDelta.current = 0
|
||||
}
|
||||
},
|
||||
[realEntryId, handleCloseGesture],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!realEntryId || !entryContentRef.current) return
|
||||
|
||||
const element = entryContentRef.current
|
||||
|
||||
// Find the scroll area viewport element with correct Radix UI attribute
|
||||
const scrollViewport = element.querySelector("[data-radix-scroll-area-viewport]") as HTMLElement
|
||||
|
||||
// Add wheel event listener to both the main container and scroll viewport
|
||||
// This ensures the gesture works in both header area and scrollable content
|
||||
const elementsToListen: HTMLElement[] = [element]
|
||||
if (scrollViewport) {
|
||||
elementsToListen.push(scrollViewport)
|
||||
}
|
||||
|
||||
elementsToListen.forEach((el) => {
|
||||
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])
|
||||
|
||||
// AI chat resizable panel configuration
|
||||
const aiColWidth = useMemo(() => getUISettings().aiColWidth, [])
|
||||
const startDragPosition = useRef(0)
|
||||
|
|
@ -54,17 +140,39 @@ const AIEntryLayoutImpl = () => {
|
|||
<EntryColumn key="entry-list" />
|
||||
|
||||
{/* Entry content overlay with exit animation */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<AnimatePresence mode="wait">
|
||||
{realEntryId && (
|
||||
<m.div
|
||||
lcpOptimization
|
||||
initial={{ y: 150, opacity: 0, scale: 0.98 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: 150, opacity: 0, scale: 0.98 }}
|
||||
transition={Spring.presets.smooth}
|
||||
ref={entryContentRef}
|
||||
key={realEntryId}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={Spring.presets.microRebound}
|
||||
className="bg-theme-background absolute inset-0 z-10 border-l"
|
||||
>
|
||||
<EntryContent entryId={realEntryId} className="h-full" />
|
||||
{/* 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"
|
||||
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>
|
||||
<EntryContent entryId={realEntryId} className="h-[calc(100%-2.25rem)]" />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { throttle } from "es-toolkit"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
interface UseWheelGestureCloseOptions {
|
||||
/** Whether the gesture is enabled */
|
||||
enabled: boolean
|
||||
/** Callback to execute when close gesture is triggered */
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface UseWheelGestureCloseReturn {
|
||||
/** Whether to show scroll hint indicator */
|
||||
showScrollHint: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for handling wheel gesture to close entry
|
||||
* Handles trackpad/mouse wheel upward scroll when at top of content
|
||||
*/
|
||||
export const useWheelGestureClose = ({
|
||||
enabled,
|
||||
onClose: handleCloseGesture,
|
||||
}: UseWheelGestureCloseOptions): UseWheelGestureCloseReturn => {
|
||||
const $scrollAreaElement = useScrollViewElement()
|
||||
const accumulatedDelta = useRef(0)
|
||||
const isScrollingAtTop = useRef(false)
|
||||
const [showScrollHint, setShowScrollHint] = useState(false)
|
||||
|
||||
const handleWheel = useEventCallback(
|
||||
throttle((e: WheelEvent) => {
|
||||
if (!enabled) return
|
||||
|
||||
// Find the actual scroll viewport element with correct Radix UI attribute
|
||||
|
||||
const scrollElement = $scrollAreaElement
|
||||
|
||||
// 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
|
||||
if (e.deltaY < 0 && isScrollingAtTop.current) {
|
||||
e.preventDefault()
|
||||
accumulatedDelta.current += Math.abs(e.deltaY)
|
||||
|
||||
// Close when accumulated scroll exceeds threshold (150px for trackpad sensitivity)
|
||||
if (accumulatedDelta.current > 1000) {
|
||||
handleCloseGesture()
|
||||
accumulatedDelta.current = 0
|
||||
}
|
||||
} else {
|
||||
// Reset accumulation when scrolling down or not at top
|
||||
accumulatedDelta.current = 0
|
||||
}
|
||||
}, 16),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!$scrollAreaElement) return
|
||||
// Find the scroll area viewport element with correct Radix UI attribute
|
||||
|
||||
// Add wheel event listener to both the main container and scroll viewport
|
||||
// This ensures the gesture works in both header area and scrollable content
|
||||
const elementsToListen: HTMLElement[] = [$scrollAreaElement]
|
||||
|
||||
elementsToListen.forEach((el) => {
|
||||
el.addEventListener("wheel", handleWheel, { passive: false })
|
||||
})
|
||||
|
||||
// Initial scroll position check for hint visibility
|
||||
const initialCheckScrollPosition = () => {
|
||||
if (!$scrollAreaElement) return
|
||||
const scrollTop = $scrollAreaElement.scrollTop || 0
|
||||
setShowScrollHint(scrollTop === 0)
|
||||
}
|
||||
|
||||
// Check initial position
|
||||
initialCheckScrollPosition()
|
||||
|
||||
// Add scroll listener for hint visibility
|
||||
|
||||
$scrollAreaElement.addEventListener("scroll", initialCheckScrollPosition, { passive: true })
|
||||
|
||||
return () => {
|
||||
elementsToListen.forEach((el) => {
|
||||
el.removeEventListener("wheel", handleWheel)
|
||||
})
|
||||
$scrollAreaElement.removeEventListener("scroll", initialCheckScrollPosition)
|
||||
}
|
||||
}, [$scrollAreaElement, handleWheel])
|
||||
|
||||
return {
|
||||
showScrollHint,
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,8 @@ import { createAtomHooks } from "~/lib/jotai"
|
|||
export const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] = createAtomHooks(
|
||||
atom(
|
||||
null as Nullable<{
|
||||
entryTitle: string
|
||||
feedTitle: string
|
||||
|
||||
// id-set
|
||||
feedId: string
|
||||
entryId: string
|
||||
title: string
|
||||
description: string
|
||||
}>,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
import { useEntry, useEntryReadHistory } from "@follow/store/entry/hooks"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { useInboxById } from "@follow/store/inbox/hooks"
|
||||
import { useEntryTranslation } from "@follow/store/translation/hooks"
|
||||
import { useWhoami } from "@follow/store/user/hooks"
|
||||
import { formatEstimatedMins, formatTimeToSeconds } from "@follow/utils"
|
||||
import { titleCase } from "title-case"
|
||||
import { useShallow } from "zustand/shallow"
|
||||
|
|
@ -10,7 +11,6 @@ import { useShowAITranslation } from "~/atoms/ai-translation"
|
|||
import { useActionLanguage } from "~/atoms/settings/general"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { RelativeTime } from "~/components/ui/datetime"
|
||||
import { useFeature } from "~/hooks/biz/useFeature"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
|
||||
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
|
||||
|
|
@ -18,7 +18,6 @@ import { FeedIcon } from "~/modules/feed/feed-icon"
|
|||
import { getPreferredTitle } from "~/store/feed/hooks"
|
||||
|
||||
import { EntryTranslation } from "../../entry-column/translation"
|
||||
import { EntryReadHistory } from "./entry-read-history"
|
||||
|
||||
interface EntryLinkProps {
|
||||
entryId: string
|
||||
|
|
@ -26,6 +25,7 @@ interface EntryLinkProps {
|
|||
}
|
||||
|
||||
export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
|
||||
const user = useWhoami()
|
||||
const entry = useEntry(
|
||||
entryId,
|
||||
useShallow((state) => {
|
||||
|
|
@ -58,11 +58,10 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
|
|||
}),
|
||||
)
|
||||
|
||||
const aiEnabled = useFeature("ai")
|
||||
const hideRecentReader = useUISettingKey("hideRecentReader")
|
||||
|
||||
const feed = useFeedById(entry?.feedId)
|
||||
const inbox = useInboxById(entry?.inboxId)
|
||||
const data = useEntryReadHistory(entryId)
|
||||
const entryHistory = data?.entryReadHistories
|
||||
const populatedFullHref = useFeedSafeUrl(entryId)
|
||||
const enableTranslation = useShowAITranslation()
|
||||
const actionLanguage = useActionLanguage()
|
||||
|
|
@ -76,6 +75,8 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
|
|||
|
||||
const navigateEntry = useNavigateEntry()
|
||||
|
||||
const hideRecentReader = useUISettingKey("hideRecentReader")
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
return compact ? (
|
||||
|
|
@ -93,16 +94,6 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
|
|||
) : (
|
||||
<div className="group relative block min-w-0 rounded-lg">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Recent Readers */}
|
||||
{aiEnabled &&
|
||||
(hideRecentReader ? (
|
||||
<div className="h-8" />
|
||||
) : (
|
||||
<div className="-mb-2 mt-2 flex h-8 items-center">
|
||||
<EntryReadHistory entryId={entryId} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<a
|
||||
href={populatedFullHref ?? "#"}
|
||||
target="_blank"
|
||||
|
|
@ -164,6 +155,19 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
|
|||
<span className="text-xs tabular-nums">{entry.estimatedMins}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const readCount =
|
||||
(entryHistory?.readCount ?? 0) +
|
||||
(entryHistory?.userIds?.every((id) => id !== user?.id) ? 1 : 0)
|
||||
|
||||
return readCount > 0 && !hideRecentReader ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<i className="i-mgc-eye-2-cute-re text-base" />
|
||||
<span className="text-xs tabular-nums">{readCount.toLocaleString()}</span>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
|
|
@ -12,33 +11,26 @@ import { useIsInbox } from "@follow/store/inbox/hooks"
|
|||
import { thenable } from "@follow/utils"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { JSAnimation } from "motion/react"
|
||||
import { AnimatePresence, useAnimationControls } from "motion/react"
|
||||
import { useAnimationControls } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
import { useEntryIsInReadability } from "~/atoms/readability"
|
||||
import { useIsZenMode } from "~/atoms/settings/ui"
|
||||
import { Focusable, FocusablePresets } from "~/components/common/Focusable"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useFeature } from "~/hooks/biz/useFeature"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
|
||||
import { useBlockActions } from "~/modules/ai-chat/store/hooks"
|
||||
import { BlockSliceAction } from "~/modules/ai-chat/store/slices/block.slice"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
|
||||
import { useWheelGestureClose } from "~/modules/entry-column/hooks/useWheelGestureClose"
|
||||
|
||||
import { ApplyEntryActions } from "../../ApplyEntryActions"
|
||||
import { NAVIGATION_HINTS_ICONS, NAVIGATION_HINTS_TEXT } from "../../constants/navigation-hints"
|
||||
import { useEntryContent } from "../../hooks"
|
||||
import { useEntryNavigationHints } from "../../hooks/useEntryNavigationHints"
|
||||
import { AIEntryHeader } from "../entry-header"
|
||||
import { EntryTimeline } from "../EntryTimelineSidebar"
|
||||
import { getEntryContentLayout } from "../layouts"
|
||||
|
|
@ -139,7 +131,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
</RootPortal>
|
||||
<EntryTimeline entryId={entryId} className="top-48" />
|
||||
<EntryScrollArea scrollerRef={scrollerRef}>
|
||||
<EntryNavigationHandler entryId={entryId} />
|
||||
{/* Indicator for the entry */}
|
||||
{!isZenMode && isInHasTimelineView && (
|
||||
<>
|
||||
|
|
@ -254,111 +245,3 @@ const AdaptiveContentRenderer: React.FC<{
|
|||
|
||||
return <LayoutComponent entryId={entryId} compact={compact} noMedia={noMedia} />
|
||||
}
|
||||
|
||||
const EntryNavigationHandler = ({ entryId }: { entryId: string }) => {
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)
|
||||
|
||||
// Handle close gesture
|
||||
const handleCloseEntry = React.useCallback(() => {
|
||||
navigate({ entryId: null })
|
||||
}, [navigate])
|
||||
|
||||
// Enable wheel gesture to close entry when focused on entry render
|
||||
const { showScrollHint } = useWheelGestureClose({
|
||||
enabled: when,
|
||||
onClose: handleCloseEntry,
|
||||
})
|
||||
|
||||
// Navigation hints for entry content
|
||||
const {
|
||||
showFirstEntryHint,
|
||||
showScrollHint: showScrollThresholdHint,
|
||||
showBottomHint,
|
||||
} = useEntryNavigationHints({
|
||||
enabled: when && !!entryId,
|
||||
entryId,
|
||||
})
|
||||
|
||||
const isZenMode = useIsZenMode()
|
||||
// TODO: Here, do not rely on the AI switch, but should be deps on the new layout.
|
||||
const isAiEnabled = useFeature("ai")
|
||||
|
||||
const useBackHandler = isZenMode || isAiEnabled
|
||||
|
||||
useCommandHotkey({
|
||||
commandId: COMMAND_ID.layout.focusToTimeline,
|
||||
when: when && !useBackHandler,
|
||||
shortcut: "Backspace, Escape",
|
||||
})
|
||||
|
||||
const navigateToTimeline = useNavigateEntry()
|
||||
useHotkeys(
|
||||
"Escape",
|
||||
() => {
|
||||
navigateToTimeline({ entryId: null })
|
||||
},
|
||||
{ enabled: when && useBackHandler },
|
||||
)
|
||||
|
||||
// Render hint button with different states
|
||||
const renderHintButton = (icon: string, text: string, position: "top" | "bottom" = "top") => (
|
||||
<m.div
|
||||
initial={{ y: position === "top" ? -50 : 50 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: position === "top" ? -50 : 50 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className={clsx(
|
||||
"pointer-events-none absolute z-40 flex justify-center",
|
||||
position === "top" ? "inset-x-0 top-4" : "inset-x-0 bottom-24",
|
||||
)}
|
||||
>
|
||||
<m.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={handleCloseEntry}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"group pointer-events-auto flex items-center gap-2",
|
||||
"rounded-full border px-3.5 py-2",
|
||||
"border-border/40 bg-material-ultra-thick shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:bg-material-thin/70 hover:border-border/60 active:scale-[0.98]",
|
||||
"backdrop-blur-background",
|
||||
)}
|
||||
>
|
||||
<i className={clsx(icon, "text-text/90 mr-1 size-5")} />
|
||||
<span className="text-text/90 text-left text-[13px] font-medium">{text}</span>
|
||||
</m.button>
|
||||
</m.div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{/* First entry hint */}
|
||||
{showFirstEntryHint &&
|
||||
renderHintButton(
|
||||
NAVIGATION_HINTS_ICONS.ARROW_UP,
|
||||
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
|
||||
"top",
|
||||
)}
|
||||
|
||||
{/* Scroll threshold hint or wheel gesture hint */}
|
||||
{(showScrollThresholdHint || showScrollHint) &&
|
||||
renderHintButton(
|
||||
NAVIGATION_HINTS_ICONS.ARROW_LEFT_UP,
|
||||
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
|
||||
"top",
|
||||
)}
|
||||
|
||||
{/* Bottom hint */}
|
||||
{showBottomHint &&
|
||||
renderHintButton(
|
||||
NAVIGATION_HINTS_ICONS.ARROW_TO_DOWN,
|
||||
NAVIGATION_HINTS_TEXT.ESC_EXIT,
|
||||
"bottom",
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
|
||||
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
|
|
@ -16,18 +15,17 @@ import { EventBus } from "@follow/utils/event-bus"
|
|||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { ErrorBoundary } from "@sentry/react"
|
||||
import type { JSAnimation, Variants } from "motion/react"
|
||||
import { AnimatePresence, m, useAnimationControls } from "motion/react"
|
||||
import { m, useAnimationControls } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { useEntryIsInReadability } from "~/atoms/readability"
|
||||
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { Focusable, FocusablePresets } from "~/components/common/Focusable"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
import { ShadowDOM } from "~/components/common/ShadowDOM"
|
||||
import type { TocRef } from "~/components/ui/markdown/components/Toc"
|
||||
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
|
||||
import { HotkeyScope } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
|
||||
|
|
@ -36,9 +34,7 @@ import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
|
|||
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
|
||||
|
||||
import { ApplyEntryActions } from "../../ApplyEntryActions"
|
||||
import { NAVIGATION_HINTS_ICONS, NAVIGATION_HINTS_TEXT } from "../../constants/navigation-hints"
|
||||
import { useEntryContent, useEntryMediaInfo } from "../../hooks"
|
||||
import { useEntryNavigationHints } from "../../hooks/useEntryNavigationHints"
|
||||
import { EntryHeader } from "../entry-header"
|
||||
import { EntryAttachments } from "../EntryAttachments"
|
||||
import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
|
||||
|
|
@ -141,7 +137,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
</RootPortal>
|
||||
<EntryTimelineSidebar entryId={entryId} />
|
||||
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
|
||||
<EntryNavigationHandler entryId={entryId} />
|
||||
{/* Indicator for the entry */}
|
||||
<m.div
|
||||
initial={pageMotionVariants.initial}
|
||||
|
|
@ -314,73 +309,3 @@ const Renderer: React.FC<{
|
|||
</EntryContentHTMLRenderer>
|
||||
)
|
||||
})
|
||||
|
||||
// EntryNavigationHandler for legacy version (without wheel gesture)
|
||||
const EntryNavigationHandler = ({ entryId }: { entryId: string }) => {
|
||||
const navigate = useNavigateEntry()
|
||||
const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)
|
||||
|
||||
// Handle close gesture
|
||||
const handleCloseEntry = React.useCallback(() => {
|
||||
navigate({ entryId: null })
|
||||
}, [navigate])
|
||||
|
||||
// Navigation hints for entry content (legacy version without wheel gesture)
|
||||
const { showFirstEntryHint, showScrollHint, showBottomHint } = useEntryNavigationHints({
|
||||
enabled: when && !!entryId,
|
||||
entryId,
|
||||
})
|
||||
|
||||
// Render hint button with different states
|
||||
const renderHintButton = (icon: string, text: string, position: "top" | "bottom" = "top") => (
|
||||
<m.div
|
||||
initial={{ y: position === "top" ? -50 : 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: position === "top" ? -50 : 50, opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className={clsx(
|
||||
"pointer-events-none absolute z-40 flex justify-center",
|
||||
position === "top" ? "inset-x-0 top-4" : "inset-x-0 bottom-4",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={handleCloseEntry}
|
||||
type="button"
|
||||
className={clsx(
|
||||
"group pointer-events-auto flex items-center gap-2",
|
||||
"rounded-full border px-3.5 py-2",
|
||||
"border-border/40 bg-material-ultra-thin/70 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:bg-material-thin/70 hover:border-border/60 active:scale-[0.98]",
|
||||
"backdrop-blur-background",
|
||||
)}
|
||||
>
|
||||
<i className={clsx(icon, "text-text/90 mr-1 size-5")} />
|
||||
<span className="text-text/90 text-left text-[13px] font-medium">{text}</span>
|
||||
</button>
|
||||
</m.div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout">
|
||||
{/* First entry hint */}
|
||||
{showFirstEntryHint &&
|
||||
renderHintButton(
|
||||
NAVIGATION_HINTS_ICONS.ARROW_UP,
|
||||
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
|
||||
"top",
|
||||
)}
|
||||
|
||||
{/* Scroll threshold hint */}
|
||||
{showScrollHint &&
|
||||
renderHintButton(
|
||||
NAVIGATION_HINTS_ICONS.ARROW_UP,
|
||||
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
|
||||
"top",
|
||||
)}
|
||||
|
||||
{/* Bottom hint */}
|
||||
{showBottomHint &&
|
||||
renderHintButton(NAVIGATION_HINTS_ICONS.CLOSE, NAVIGATION_HINTS_TEXT.ESC_EXIT, "bottom")}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ export const EntryTitleMetaHandler: Component<{
|
|||
|
||||
useEffect(() => {
|
||||
if (entry?.title && feedTitle) {
|
||||
setEntryTitleMeta({ entryTitle: entry.title, feedTitle, feedId: entry.feedId!, entryId })
|
||||
setEntryTitleMeta({ title: entry.title, description: feedTitle })
|
||||
}
|
||||
return () => {
|
||||
setEntryTitleMeta(null)
|
||||
}
|
||||
}, [entryId, entry?.title, feedTitle, entry?.feedId])
|
||||
}, [entryId, entry?.title, feedTitle])
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@ import { memo } from "react"
|
|||
|
||||
import { EntryHeaderRoot } from "./internal/context"
|
||||
import { EntryHeaderActionsContainer } from "./internal/EntryHeaderActionsContainer"
|
||||
import { EntryHeaderBreadcrumb } from "./internal/EntryHeaderBreadcrumb"
|
||||
import { EntryHeaderMeta } from "./internal/EntryHeaderMeta"
|
||||
import { EntryHeaderReadHistory } from "./internal/EntryHeaderReadHistory"
|
||||
import type { EntryHeaderProps } from "./types"
|
||||
|
||||
function EntryHeaderImpl({ entryId, className, compact }: EntryHeaderProps) {
|
||||
return (
|
||||
<EntryHeaderRoot entryId={entryId} className={className} compact={compact}>
|
||||
<EntryHeaderReadHistory />
|
||||
<div
|
||||
className="relative z-10 flex w-full items-center justify-between gap-3"
|
||||
data-hide-in-print
|
||||
>
|
||||
<EntryHeaderBreadcrumb />
|
||||
<EntryHeaderMeta />
|
||||
<EntryHeaderActionsContainer />
|
||||
</div>
|
||||
</EntryHeaderRoot>
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ export * from "./EntryHeader"
|
|||
export * from "./internal/context"
|
||||
export * from "./internal/EntryHeaderActionsContainer"
|
||||
export * from "./internal/EntryHeaderMeta"
|
||||
export * from "./internal/EntryHeaderReadHistory"
|
||||
export * from "./types"
|
||||
|
|
|
|||
|
|
@ -1,294 +0,0 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { getEntry, getEntryIdsByFeedId } from "@follow/store/entry/getter"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { useListById } from "@follow/store/list/hooks"
|
||||
import {
|
||||
getFeedSubscriptionByViewSelector,
|
||||
getListSubscriptionByViewSelector,
|
||||
} from "@follow/store/subscription/getter"
|
||||
import type {
|
||||
useFeedSubscriptionByView,
|
||||
useListSubscriptionByView,
|
||||
} from "@follow/store/subscription/hooks"
|
||||
import { useSubscriptionStore } from "@follow/store/subscription/store"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useForceUpdate } from "motion/react"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu/dropdown-menu"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { getPreferredTitle } from "~/store/feed/hooks"
|
||||
|
||||
import { useEntryTitleMeta } from "../../../atoms"
|
||||
import { useEntryHeaderContext } from "./context"
|
||||
|
||||
const Slash = (
|
||||
<i className="i-mingcute-line-line text-text-tertiary size-4 shrink-0 rotate-[-25deg]" />
|
||||
)
|
||||
|
||||
function ViewSubscriptionsDropdown({
|
||||
view,
|
||||
onNavigate,
|
||||
}: {
|
||||
view: number
|
||||
onNavigate: ReturnType<typeof useNavigateEntry>
|
||||
}) {
|
||||
const feedSubsRef = useRef<ReturnType<typeof useFeedSubscriptionByView>>([])
|
||||
const listSubsRef = useRef<ReturnType<typeof useListSubscriptionByView>>([])
|
||||
const [forceUpdate] = useForceUpdate()
|
||||
|
||||
const handleRefreshDropDownData = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
// Get fresh data from store
|
||||
const state = useSubscriptionStore.getState()
|
||||
const feedSubs = getFeedSubscriptionByViewSelector(state)(view)
|
||||
const listSubs = getListSubscriptionByViewSelector(state)(view)
|
||||
|
||||
feedSubsRef.current = feedSubs || []
|
||||
listSubsRef.current = listSubs || []
|
||||
|
||||
forceUpdate()
|
||||
},
|
||||
[view, forceUpdate],
|
||||
)
|
||||
|
||||
const routeParams = getRouteParams()
|
||||
const { isAllFeeds, listId, feedId } = routeParams
|
||||
|
||||
// Check if there's any subscription data for this view (without causing re-render)
|
||||
// This allows initial render to show the dropdown if subscriptions exist
|
||||
const state = useSubscriptionStore.getState()
|
||||
const initialFeedSubs = getFeedSubscriptionByViewSelector(state)(view)
|
||||
const initialListSubs = getListSubscriptionByViewSelector(state)(view)
|
||||
const hasAnyInitial = (initialFeedSubs?.length ?? 0) + (initialListSubs?.length ?? 0) > 0
|
||||
|
||||
if (!hasAnyInitial) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-1 inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Open subscriptions of this view"
|
||||
>
|
||||
<i className="i-mingcute-down-line size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<ScrollArea.ScrollArea
|
||||
flex
|
||||
rootClassName="max-h-[60vh] min-h-0 relative min-w-64"
|
||||
viewportClassName="max-h-[60vh]"
|
||||
>
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onNavigate({ entryId: null, view })}
|
||||
checked={isAllFeeds}
|
||||
>
|
||||
<span className="truncate">All</span>
|
||||
</DropdownMenuItem>
|
||||
{listSubsRef.current && listSubsRef.current.length > 0 && (
|
||||
<div className="text-text-tertiary px-2 py-1 text-xs">Lists</div>
|
||||
)}
|
||||
{listSubsRef.current?.map((s) =>
|
||||
s.listId ? (
|
||||
<DropdownMenuItem
|
||||
checked={s.listId === listId}
|
||||
key={`list-${s.listId}`}
|
||||
onClick={() => s.listId && onNavigate({ entryId: null, listId: s.listId })}
|
||||
>
|
||||
<ListNameItem listId={s.listId} />
|
||||
</DropdownMenuItem>
|
||||
) : null,
|
||||
)}
|
||||
{feedSubsRef.current && feedSubsRef.current.length > 0 && (
|
||||
<div className="text-text-tertiary px-2 py-1 text-xs">Feeds</div>
|
||||
)}
|
||||
{feedSubsRef.current?.map((s) =>
|
||||
s.feedId ? (
|
||||
<DropdownMenuItem
|
||||
checked={s.feedId === feedId}
|
||||
key={`feed-${s.feedId}`}
|
||||
onClick={() => s.feedId && onNavigate({ entryId: null, feedId: s.feedId })}
|
||||
>
|
||||
<FeedNameItem feedId={s.feedId} />
|
||||
</DropdownMenuItem>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const ListNameItem = ({ listId }: { listId: string }) => {
|
||||
const name = useListById(listId, (s) => s?.title)
|
||||
if (!name) return null
|
||||
return <span className="truncate">{name}</span>
|
||||
}
|
||||
|
||||
const FeedNameItem = ({ feedId }: { feedId: string }) => {
|
||||
const feed = useFeedById(feedId)
|
||||
|
||||
if (!feed) return null
|
||||
return <span className="truncate">{getPreferredTitle(feed)}</span>
|
||||
}
|
||||
|
||||
function FeedEntriesDropdown({
|
||||
feedId,
|
||||
currentEntryId,
|
||||
onNavigate,
|
||||
}: {
|
||||
feedId: string
|
||||
currentEntryId: string
|
||||
onNavigate: ReturnType<typeof useNavigateEntry>
|
||||
}) {
|
||||
const siblingEntriesRef = useRef<{ id: string; title: string }[]>([])
|
||||
const [forceUpdate] = useForceUpdate()
|
||||
|
||||
const handleRefreshDropDownData = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
const entryIds = getEntryIdsByFeedId(feedId)
|
||||
if (!entryIds) return
|
||||
|
||||
siblingEntriesRef.current = []
|
||||
for (const entryId of entryIds) {
|
||||
const entry = getEntry(entryId)
|
||||
if (!entry) continue
|
||||
const { title } = entry
|
||||
if (!title) continue
|
||||
siblingEntriesRef.current.push({ id: entryId, title })
|
||||
}
|
||||
|
||||
forceUpdate()
|
||||
},
|
||||
[feedId, forceUpdate],
|
||||
)
|
||||
|
||||
// Check if there are any entries for this feed
|
||||
const entryIds = getEntryIdsByFeedId(feedId)
|
||||
if (!entryIds || entryIds.length <= 1) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-2 inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Open entries from this feed"
|
||||
>
|
||||
<i className="i-mingcute-down-line size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<ScrollArea.ScrollArea
|
||||
rootClassName="max-h-[60vh] min-w-64"
|
||||
viewportClassName="max-h-[60vh]"
|
||||
>
|
||||
<div className="p-1">
|
||||
{siblingEntriesRef.current.map((e) => (
|
||||
<DropdownMenuItem
|
||||
key={e.id}
|
||||
onClick={() => onNavigate({ entryId: e.id })}
|
||||
checked={e.id === currentEntryId}
|
||||
>
|
||||
<span className="truncate" title={e.title}>
|
||||
{e.title}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryHeaderBreadcrumb() {
|
||||
const meta = useEntryTitleMeta()
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
const { entryId } = useEntryHeaderContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
if (!meta) return null
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={
|
||||
"text-text-secondary group/breadcrumb flex min-w-0 items-center gap-1 truncate leading-tight"
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{/* Return Back Button */}
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] shrink-0 items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors"
|
||||
onClick={() => navigate({ entryId: null })}
|
||||
>
|
||||
<i className="i-mingcute-arrow-left-line size-4" />
|
||||
</button>
|
||||
{views[view]?.name && (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
|
||||
)}
|
||||
onClick={() => navigate({ entryId: null, view })}
|
||||
>
|
||||
<span className="text-text-secondary text-sm">
|
||||
{t(views[view]?.name, { ns: "common" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ViewSubscriptionsDropdown view={view} onNavigate={navigate} />
|
||||
</div>
|
||||
)}
|
||||
{Slash}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
|
||||
)}
|
||||
onClick={() => navigate({ entryId: null, feedId: meta.feedId })}
|
||||
title={meta.feedTitle}
|
||||
>
|
||||
<span className="truncate">{meta.feedTitle}</span>
|
||||
</button>
|
||||
|
||||
<FeedEntriesDropdown
|
||||
feedId={meta.feedId}
|
||||
currentEntryId={entryId}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Slash}
|
||||
|
||||
<span className="text-text truncate px-1.5 py-0.5 text-sm" title={meta.entryTitle}>
|
||||
{meta.entryTitle}
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { useEntryContentScrollToTop, useEntryTitleMeta } from "../../../atoms"
|
|||
function EntryHeaderMetaImpl() {
|
||||
const entryTitleMeta = useEntryTitleMeta()
|
||||
const isAtTop = useEntryContentScrollToTop()
|
||||
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.entryTitle
|
||||
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.title
|
||||
return (
|
||||
<div className="flex min-w-0 shrink grow">
|
||||
<AnimatePresence>
|
||||
|
|
@ -17,10 +17,10 @@ function EntryHeaderMetaImpl() {
|
|||
exit={{ opacity: 0.01, y: 30 }}
|
||||
className="text-text text-title3 flex min-w-0 flex-1 shrink items-end gap-2 truncate leading-tight"
|
||||
>
|
||||
<span className="shrink truncate font-bold">{entryTitleMeta.entryTitle}</span>
|
||||
<span className="shrink truncate font-bold">{entryTitleMeta.title}</span>
|
||||
<i className="i-mgc-line-cute-re text-text-secondary size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
|
||||
<span className="text-text-secondary text-headline shrink -translate-y-px truncate">
|
||||
{entryTitleMeta.feedTitle}
|
||||
{entryTitleMeta.description}
|
||||
</span>
|
||||
</m.div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
/**
|
||||
* Constants for entry navigation hints behavior
|
||||
*/
|
||||
export const NAVIGATION_HINTS_CONSTANTS = {
|
||||
/** Default scroll threshold to trigger scroll hint (px) */
|
||||
DEFAULT_SCROLL_THRESHOLD: 100,
|
||||
|
||||
/** Delay before showing first entry hint (ms) */
|
||||
FIRST_HINT_DELAY: 500,
|
||||
|
||||
/** Duration to show hints before auto-hiding (ms) */
|
||||
HINT_DISPLAY_DURATION: 3000,
|
||||
|
||||
/** Distance from bottom to trigger bottom hint (px) */
|
||||
BOTTOM_THRESHOLD: 50,
|
||||
|
||||
/** Distance from bottom to hide bottom hint when scrolling up (px) */
|
||||
BOTTOM_HIDE_THRESHOLD: 100,
|
||||
|
||||
/** Throttle interval for scroll handler (ms) */
|
||||
SCROLL_THROTTLE_INTERVAL: 100,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Text constants for navigation hints
|
||||
*/
|
||||
export const NAVIGATION_HINTS_TEXT = {
|
||||
SCROLL_UP_EXIT: "Scroll up or click left-top back button to exit",
|
||||
ESC_EXIT: "Press ESC or click left-top back button to exit",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Icon constants for navigation hints
|
||||
*/
|
||||
export const NAVIGATION_HINTS_ICONS = {
|
||||
ARROW_UP: "i-mgc-up-cute-re",
|
||||
ARROW_LEFT_UP: "i-mingcute-arrow-left-up-line",
|
||||
ARROW_TO_DOWN: "i-mingcute-arrow-to-down-line",
|
||||
CLOSE: "i-mgc-close-cute-re",
|
||||
} as const
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { throttle } from "es-toolkit"
|
||||
import { startTransition, useEffect, useRef, useState } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { NAVIGATION_HINTS_CONSTANTS } from "../constants/navigation-hints"
|
||||
|
||||
interface UseEntryNavigationHintsOptions {
|
||||
/** Whether hints are enabled */
|
||||
enabled: boolean
|
||||
/** Entry ID to track changes */
|
||||
entryId?: string
|
||||
/** Scroll threshold to show hint */
|
||||
scrollThreshold?: number
|
||||
}
|
||||
|
||||
interface UseEntryNavigationHintsReturn {
|
||||
/** Show hint for first entry */
|
||||
showFirstEntryHint: boolean
|
||||
/** Show hint when scrolled past threshold */
|
||||
showScrollHint: boolean
|
||||
/** Show hint when at bottom */
|
||||
showBottomHint: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing entry navigation hints
|
||||
* Shows contextual hints based on scroll position and entry state
|
||||
*/
|
||||
export const useEntryNavigationHints = ({
|
||||
enabled,
|
||||
entryId,
|
||||
scrollThreshold = NAVIGATION_HINTS_CONSTANTS.DEFAULT_SCROLL_THRESHOLD,
|
||||
}: UseEntryNavigationHintsOptions): UseEntryNavigationHintsReturn => {
|
||||
const $scrollElement = useScrollViewElement()
|
||||
|
||||
// State for different hint types
|
||||
const [showFirstEntryHint, setShowFirstEntryHint] = useState(false)
|
||||
const [showScrollHint, setShowScrollHint] = useState(false)
|
||||
const [showBottomHint, setShowBottomHint] = useState(false)
|
||||
|
||||
// Refs to track state
|
||||
const hasShownFirstHintRef = useRef(false)
|
||||
const hasShownScrollHintRef = useRef(false)
|
||||
const hasShownBottomHintRef = useRef(false)
|
||||
const currentEntryIdRef = useRef<string>(void 0)
|
||||
const firstHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)
|
||||
const scrollHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)
|
||||
const bottomHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const scrollDirectionRef = useRef<"up" | "down" | "none">("none")
|
||||
|
||||
// Reset hints when entry changes
|
||||
useEffect(() => {
|
||||
if (entryId && entryId !== currentEntryIdRef.current) {
|
||||
currentEntryIdRef.current = entryId
|
||||
hasShownFirstHintRef.current = false
|
||||
hasShownScrollHintRef.current = false
|
||||
hasShownBottomHintRef.current = false
|
||||
lastScrollTopRef.current = 0
|
||||
scrollDirectionRef.current = "none"
|
||||
|
||||
// Clear existing timers
|
||||
if (firstHintTimerRef.current) clearTimeout(firstHintTimerRef.current)
|
||||
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)
|
||||
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
|
||||
|
||||
// Reset all hint states with low priority
|
||||
startTransition(() => {
|
||||
setShowFirstEntryHint(false)
|
||||
setShowScrollHint(false)
|
||||
setShowBottomHint(false)
|
||||
})
|
||||
|
||||
if (enabled) {
|
||||
// Show first entry hint after a brief delay
|
||||
firstHintTimerRef.current = setTimeout(() => {
|
||||
if (!hasShownFirstHintRef.current) {
|
||||
startTransition(() => {
|
||||
setShowFirstEntryHint(true)
|
||||
})
|
||||
hasShownFirstHintRef.current = true
|
||||
|
||||
// Hide after configured duration
|
||||
firstHintTimerRef.current = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
setShowFirstEntryHint(false)
|
||||
})
|
||||
}, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)
|
||||
}
|
||||
}, NAVIGATION_HINTS_CONSTANTS.FIRST_HINT_DELAY) // Small delay to allow content to load
|
||||
}
|
||||
}
|
||||
}, [entryId, enabled])
|
||||
|
||||
// Scroll handler to manage hints based on scroll position
|
||||
const handleScroll = useEventCallback(
|
||||
throttle(() => {
|
||||
if (!enabled || !$scrollElement) return
|
||||
|
||||
const { scrollTop } = $scrollElement
|
||||
const { scrollHeight } = $scrollElement
|
||||
const { clientHeight } = $scrollElement
|
||||
const scrollBottom = scrollHeight - clientHeight - scrollTop
|
||||
|
||||
// Detect scroll direction
|
||||
const lastScrollTop = lastScrollTopRef.current
|
||||
if (scrollTop > lastScrollTop) {
|
||||
scrollDirectionRef.current = "down"
|
||||
} else if (scrollTop < lastScrollTop) {
|
||||
scrollDirectionRef.current = "up"
|
||||
}
|
||||
lastScrollTopRef.current = scrollTop
|
||||
|
||||
// Check if scrolled past threshold and scrolling up
|
||||
if (
|
||||
scrollTop > scrollThreshold &&
|
||||
!hasShownScrollHintRef.current &&
|
||||
scrollDirectionRef.current === "up"
|
||||
) {
|
||||
hasShownScrollHintRef.current = true
|
||||
startTransition(() => {
|
||||
setShowScrollHint(true)
|
||||
})
|
||||
|
||||
// Clear previous timer
|
||||
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)
|
||||
|
||||
// Hide after configured duration
|
||||
scrollHintTimerRef.current = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
setShowScrollHint(false)
|
||||
})
|
||||
}, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)
|
||||
}
|
||||
|
||||
// Check if at bottom (within configured threshold)
|
||||
if (
|
||||
scrollBottom <= NAVIGATION_HINTS_CONSTANTS.BOTTOM_THRESHOLD &&
|
||||
!hasShownBottomHintRef.current
|
||||
) {
|
||||
hasShownBottomHintRef.current = true
|
||||
startTransition(() => {
|
||||
setShowBottomHint(true)
|
||||
})
|
||||
|
||||
// Clear previous timer
|
||||
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
|
||||
|
||||
// Hide after configured duration
|
||||
bottomHintTimerRef.current = setTimeout(() => {
|
||||
startTransition(() => {
|
||||
setShowBottomHint(false)
|
||||
})
|
||||
hasShownBottomHintRef.current = false
|
||||
}, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)
|
||||
}
|
||||
|
||||
// Hide bottom hint if user scrolls up from bottom
|
||||
if (
|
||||
scrollBottom > NAVIGATION_HINTS_CONSTANTS.BOTTOM_HIDE_THRESHOLD &&
|
||||
hasShownBottomHintRef.current &&
|
||||
scrollDirectionRef.current === "up"
|
||||
) {
|
||||
// Clear timer if exists
|
||||
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
|
||||
|
||||
startTransition(() => {
|
||||
setShowBottomHint(false)
|
||||
})
|
||||
hasShownBottomHintRef.current = false
|
||||
}
|
||||
}, NAVIGATION_HINTS_CONSTANTS.SCROLL_THROTTLE_INTERVAL),
|
||||
)
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
if (!enabled || !$scrollElement) return
|
||||
|
||||
$scrollElement.addEventListener("scroll", handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
$scrollElement.removeEventListener("scroll", handleScroll)
|
||||
}
|
||||
}, [enabled, $scrollElement, handleScroll])
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (firstHintTimerRef.current) clearTimeout(firstHintTimerRef.current)
|
||||
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)
|
||||
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
showFirstEntryHint,
|
||||
showScrollHint,
|
||||
showBottomHint,
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@ const microDampingPreset: Transition = {
|
|||
|
||||
const microReboundPreset: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
duration: 0.3,
|
||||
}
|
||||
|
||||
const softSpringPreset: Transition = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue