fix: align entry details column layout with ai panel states

This commit is contained in:
DIYgod 2025-10-21 15:10:20 +08:00
parent 36bc50b1c0
commit 878ad81a7d
No known key found for this signature in database
4 changed files with 166 additions and 51 deletions

View File

@ -0,0 +1,17 @@
import { FeedViewType } from "@follow/constants"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export const useShowEntryDetailsColumn = () => {
const { view } = useRouteParamsSelector((s) => ({
view: s.view,
}))
const aiPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
return (
view === FeedViewType.Articles &&
(aiPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible)
)
}

View File

@ -3,20 +3,23 @@ import { PanelSplitter } from "@follow/components/ui/divider/index.js"
import { FeedViewType } from "@follow/constants"
import { defaultUISettings } from "@follow/shared/settings/defaults"
import { cn } from "@follow/utils"
import { isSafari } from "@follow/utils/utils"
import { AnimatePresence } from "motion/react"
import { memo, startTransition, useCallback, useEffect, useMemo, useRef } from "react"
import { useResizable } from "react-resizable-layout"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
import { getUISettings, setUISetting, useUISettingKey } from "~/atoms/settings/ui"
import { setSubscriptionColumnApronNode, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useShowEntryDetailsColumn } from "~/hooks/biz/useShowEntryDetailsColumn"
import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot"
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
import { AIIndicator } from "~/modules/app-layout/ai/AISplineButton"
import { EntryContentPlaceholder } from "~/modules/app-layout/entry-content/EntryContentPlaceholder"
import { EntryColumn } from "~/modules/entry-column"
import { EntryPlaneToolbar } from "~/modules/entry-column/components/EntryPlaneToolbar"
import { EntrySubscriptionList } from "~/modules/entry-column/EntrySubscriptionList"
@ -64,6 +67,40 @@ const AIEnhancedTimelineLayoutImpl = () => {
entryId: s.entryId,
}))
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const showEntryDetailsColumn = useShowEntryDetailsColumn()
const layoutContainerRef = useRef<HTMLDivElement>(null)
const feedColumnWidth = useUISettingKey("feedColWidth")
// Timeline column resizable configuration (used when entry details column is visible)
const entryColumnInitialWidth = useMemo(() => getUISettings().entryColWidth, [])
const timelineMaxWidth = useMemo(() => {
if (typeof window === "undefined") return 600
return Math.max((window.innerWidth - feedColumnWidth) / 2, 600)
}, [feedColumnWidth])
const timelineStartDragPosition = useRef(0)
const {
position: timelineColumnWidth,
separatorProps: timelineSeparatorProps,
isDragging: isTimelineDragging,
separatorCursor: timelineSeparatorCursor,
setPosition: setTimelineColumnWidth,
} = useResizable({
axis: "x",
min: isSafari() ? 356 : 300,
max: timelineMaxWidth,
initial: entryColumnInitialWidth,
containerRef: layoutContainerRef as React.RefObject<HTMLElement>,
onResizeStart({ position }) {
timelineStartDragPosition.current = position
},
onResizeEnd({ position }) {
if (position === timelineStartDragPosition.current) return
setUISetting("entryColWidth", position)
// TODO: Remove this after useMeasure can get bounds in time
window.dispatchEvent(new Event("resize"))
},
})
// AI chat resizable panel configuration
const isAllView = view === FeedViewType.All
@ -88,19 +125,25 @@ const AIEnhancedTimelineLayoutImpl = () => {
// Calculate initial width depending on view
const initialAiWidth = useMemo(() => resolvePreferredWidth(), [resolvePreferredWidth])
const startDragPosition = useRef(0)
const aiPanelStartDragPosition = useRef(0)
const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({
const {
position: aiPanelWidth,
separatorProps: aiSeparatorProps,
isDragging: isAiPanelDragging,
separatorCursor: aiSeparatorCursor,
setPosition: setAiPanelWidth,
} = useResizable({
axis: "x",
min: minWidth,
max: maxWidth,
initial: initialAiWidth,
reverse: true,
onResizeStart({ position }) {
startDragPosition.current = position
aiPanelStartDragPosition.current = position
},
onResizeEnd({ position }) {
if (position === startDragPosition.current) return
if (position === aiPanelStartDragPosition.current) return
// Persist per-view width
setUISetting(isAllView ? "aiColWidthAll" : "aiColWidth", position)
// TODO: Remove this after useMeasure can get bounds in time
@ -111,54 +154,102 @@ const AIEnhancedTimelineLayoutImpl = () => {
// When view changes, switch to the saved width for that view
useEffect(() => {
const width = resolvePreferredWidth()
setPosition(width)
setAiPanelWidth(width)
// Trigger layout update
window.dispatchEvent(new Event("resize"))
}, [resolvePreferredWidth])
const showCompactTimelineColumn = useGeneralSettingKey("showCompactTimelineInSub")
return (
<div className="relative flex min-w-0 grow">
<div
className={cn(
"h-full min-w-[300px] flex-1",
aiPanelStyle === AIChatPanelStyle.Fixed && "border-r",
)}
>
<div ref={layoutContainerRef} className="relative flex min-w-0 grow">
<div className="relative min-w-0 flex-1">
<AppLayoutGridContainerProvider>
<div className="relative h-full">
{/* Entry list - always rendered to prevent animation */}
<EntryColumn key="entry-list" />
<AnimatePresence>
{realEntryId && (
<m.div
className="absolute inset-x-0 top-0 z-10"
initial={{ translateY: "-50px", opacity: 0 }}
animate={{ translateY: 0, opacity: 1 }}
exit={{ translateY: "-50px", opacity: 0 }}
transition={Spring.smooth(0.3)}
>
<AIEntryHeader entryId={realEntryId} />
</m.div>
<div
className={cn(
"absolute inset-y-0 left-0 min-w-[300px]",
showEntryDetailsColumn ? "border-r will-change-[width]" : "right-0",
aiPanelStyle === AIChatPanelStyle.Fixed && !showEntryDetailsColumn && "border-r",
)}
</AnimatePresence>
{/* Entry content overlay with exit animation */}
<AnimatePresence>
{realEntryId && (
<div className="pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden">
<m.div
lcpOptimization
initial={{ translateY: "50px", opacity: 0, scale: 0.98 }}
animate={{ translateY: 0, opacity: 1, scale: 1 }}
exit={{ translateY: "50px", opacity: 0, scale: 0.98 }}
transition={Spring.smooth(0.3)}
className="bg-theme-background pointer-events-auto relative flex h-0 flex-1 flex-col"
>
<EntryContent entryId={realEntryId} className="h-full" />
</m.div>
style={showEntryDetailsColumn ? { width: timelineColumnWidth } : undefined}
>
<div className="relative h-full">
{/* Entry list - always rendered to prevent animation */}
<EntryColumn key="entry-list" />
{!showEntryDetailsColumn && (
<>
<AnimatePresence>
{realEntryId && (
<m.div
className="absolute inset-x-0 top-0 z-10"
initial={{ translateY: "-50px", opacity: 0 }}
animate={{ translateY: 0, opacity: 1 }}
exit={{ translateY: "-50px", opacity: 0 }}
transition={Spring.smooth(0.3)}
>
<AIEntryHeader entryId={realEntryId} />
</m.div>
)}
</AnimatePresence>
{/* Entry content overlay with exit animation */}
<AnimatePresence>
{realEntryId && (
<div className="pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden">
<m.div
lcpOptimization
initial={{ translateY: "50px", opacity: 0, scale: 0.98 }}
animate={{ translateY: 0, opacity: 1, scale: 1 }}
exit={{ translateY: "50px", opacity: 0, scale: 0.98 }}
transition={Spring.smooth(0.3)}
className="bg-theme-background pointer-events-auto relative flex h-0 flex-1 flex-col"
>
<EntryContent entryId={realEntryId} className="h-full" />
</m.div>
</div>
)}
</AnimatePresence>
</>
)}
</div>
</div>
{showEntryDetailsColumn && (
<>
<div className="absolute inset-y-0 z-[2]" style={{ left: timelineColumnWidth }}>
<PanelSplitter
{...timelineSeparatorProps}
cursor={timelineSeparatorCursor}
isDragging={isTimelineDragging}
onDoubleClick={() => {
setUISetting("entryColWidth", defaultUISettings.entryColWidth)
setTimelineColumnWidth(defaultUISettings.entryColWidth)
window.dispatchEvent(new Event("resize"))
}}
/>
</div>
)}
</AnimatePresence>
<div
className="bg-theme-background absolute inset-y-0 right-0 flex min-w-0 flex-1 flex-col overflow-hidden"
style={{ left: timelineColumnWidth }}
>
{realEntryId ? (
<>
<div className="absolute inset-x-0 top-0 z-10">
<AIEntryHeader entryId={realEntryId} />
</div>
<div className="pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden">
<div className="bg-theme-background pointer-events-auto relative flex h-0 flex-1 flex-col">
<EntryContent entryId={realEntryId} className="h-full" />
</div>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center px-8">
<EntryContentPlaceholder />
</div>
)}
</div>
</>
)}
</div>
</AppLayoutGridContainerProvider>
</div>
@ -167,22 +258,25 @@ const AIEnhancedTimelineLayoutImpl = () => {
{aiSidebarVisible && (
<>
<PanelSplitter
{...separatorProps}
cursor={separatorCursor}
isDragging={isDragging}
{...aiSeparatorProps}
cursor={aiSeparatorCursor}
isDragging={isAiPanelDragging}
onDoubleClick={() => {
const resetWidth = isAllView
? getHalfScreenWidth()
: clampWidth(defaultUISettings.aiColWidth)
setUISetting(isAllView ? "aiColWidthAll" : "aiColWidth", resetWidth)
setPosition(resetWidth)
setAiPanelWidth(resetWidth)
window.dispatchEvent(new Event("resize"))
}}
/>
<AIChatLayout
key="ai-chat-layout"
style={
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
{
width: aiPanelWidth,
"--ai-chat-layout-width": `${aiPanelWidth}px`,
} as React.CSSProperties
}
/>
</>

View File

@ -13,6 +13,7 @@ import { RelativeDay } from "~/components/ui/datetime"
import { IconScaleTransition } from "~/components/ux/transition/icon"
import { useFeature } from "~/hooks/biz/useFeature"
import { getRouteParams, useRouteParams } from "~/hooks/biz/useRouteParams"
import { useShowEntryDetailsColumn } from "~/hooks/biz/useShowEntryDetailsColumn"
import { markAllByRoute } from "../hooks/useMarkAll"
@ -43,7 +44,9 @@ const useParseDate = (date: string) =>
const dateItemclassName = tw`relative flex items-center text-sm lg:text-base gap-1 px-4 font-bold text-text h-7`
export const DateItem = memo(({ date, view, isSticky }: DateItemProps) => {
const aiEnabled = useFeature("ai")
if (view === FeedViewType.SocialMedia || aiEnabled) {
const showEntryDetailsColumn = useShowEntryDetailsColumn()
if (view === FeedViewType.SocialMedia || (aiEnabled && !showEntryDetailsColumn)) {
return <SocialMediaDateItem date={date} className={dateItemclassName} isSticky={isSticky} />
}
return <UniversalDateItem date={date} className={dateItemclassName} isSticky={isSticky} />

View File

@ -7,7 +7,7 @@ import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useCallback, useMemo, useRef, useState } from "react"
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
@ -66,8 +66,9 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
}, [removeBlock])
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating
const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible
if (!entry) return null
return (