fix(ui): improve chat navigation buttons UX and fix scroll interference

This commit enhances the chat navigation buttons with a more intelligent
visibility system that prevents interference with scrolling while maintaining
easy access to navigation controls.

Key improvements:
- Replace static trigger area with dynamic cursor position tracking to allow
  unobstructed scrolling
- Show navigation buttons only when cursor is near the button area or when
  actively interacting with them
- Add throttled mouse position detection (50ms) for better performance
- Use passive scroll event listeners for smoother scrolling
- Implement smarter auto-hide behavior with 1.5s timeout when cursor leaves
  the button area

This change resolves the issue where navigation buttons would interfere with
scrolling when the cursor was in the detection area, creating a more seamless
user experience.
This commit is contained in:
Carter Cheng 2025-03-12 23:07:00 +08:00 committed by 亢奋猫
parent 145be1fd87
commit 866ce86cc0

View File

@ -1,7 +1,7 @@
import { DownOutlined, UpOutlined } from '@ant-design/icons' import { DownOutlined, UpOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { Button, Tooltip } from 'antd' import { Button, Tooltip } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -12,22 +12,53 @@ interface ChatNavigationProps {
const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => { const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false)
const [isNearButtons, setIsNearButtons] = useState(false)
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null) const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
const lastMoveTime = useRef(0)
const { topicPosition, showTopics } = useSettings() const { topicPosition, showTopics } = useSettings()
const showRightTopics = topicPosition === 'right' && showTopics const showRightTopics = topicPosition === 'right' && showTopics
const right = showRightTopics ? 'calc(var(--topic-list-width) + 16px)' : '16px' const right = showRightTopics ? 'calc(var(--topic-list-width) + 16px)' : '16px'
// Reset hide timer and make buttons visible
const resetHideTimer = useCallback(() => { const resetHideTimer = useCallback(() => {
if (hideTimer) { if (hideTimer) {
clearTimeout(hideTimer) clearTimeout(hideTimer)
} }
setIsVisible(true) setIsVisible(true)
// Only set a hide timer if cursor is not near the buttons
if (!isNearButtons) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsVisible(false) setIsVisible(false)
}, 1000) }, 1500)
setHideTimer(timer) setHideTimer(timer)
}
}, [hideTimer, isNearButtons])
// Handle mouse entering button area
const handleMouseEnter = useCallback(() => {
setIsNearButtons(true)
setIsVisible(true)
// Clear any existing hide timer
if (hideTimer) {
clearTimeout(hideTimer)
setHideTimer(null)
}
}, [hideTimer]) }, [hideTimer])
// Handle mouse leaving button area
const handleMouseLeave = useCallback(() => {
setIsNearButtons(false)
// Set a timer to hide the buttons
const timer = setTimeout(() => {
setIsVisible(false)
}, 1500)
setHideTimer(timer)
}, [])
const findUserMessages = () => { const findUserMessages = () => {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
if (!container) return [] if (!container) return []
@ -155,28 +186,84 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
scrollToMessage(userMessages[targetIndex]) scrollToMessage(userMessages[targetIndex])
} }
// Set up scroll event listener and mouse position tracking
useEffect(() => { useEffect(() => {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
if (!container) return if (!container) return
// Handle scroll events on the container
const handleScroll = () => { const handleScroll = () => {
setIsVisible(true) // Only show buttons when scrolling if cursor is near the button area
if (isNearButtons) {
resetHideTimer() resetHideTimer()
} }
}
// Throttled mouse move handler to improve performance
const handleMouseMove = (e: MouseEvent) => {
// Throttle mouse move to every 50ms for performance
const now = Date.now()
if (now - lastMoveTime.current < 50) return
lastMoveTime.current = now
// Calculate if the mouse is in the trigger area
const triggerWidth = 80 // Same as the width in styled component
// Safe way to calculate position when using calc expressions
let rightOffset = 16 // Default right offset
if (showRightTopics) {
// When topics are shown on right, we need to account for topic list width
rightOffset = 16 + 300 // Assuming topic list width is 300px, adjust if different
}
const rightPosition = window.innerWidth - rightOffset - triggerWidth
const topPosition = window.innerHeight * 0.3 // 30% from top
const height = window.innerHeight * 0.4 // 40% of window height
const isInTriggerArea =
e.clientX > rightPosition &&
e.clientX < rightPosition + triggerWidth &&
e.clientY > topPosition &&
e.clientY < topPosition + height
// Update state based on mouse position
if (isInTriggerArea && !isNearButtons) {
handleMouseEnter()
} else if (!isInTriggerArea && isNearButtons) {
// Only trigger mouse leave when not in the navigation area
// This ensures we don't leave when hovering over the actual buttons
handleMouseLeave()
}
}
// Use passive: true for better scroll performance
container.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('mousemove', handleMouseMove)
container.addEventListener('scroll', handleScroll)
return () => { return () => {
container.removeEventListener('scroll', handleScroll) container.removeEventListener('scroll', handleScroll)
window.removeEventListener('mousemove', handleMouseMove)
if (hideTimer) { if (hideTimer) {
clearTimeout(hideTimer) clearTimeout(hideTimer)
} }
} }
}, [containerId, hideTimer, resetHideTimer]) }, [
containerId,
hideTimer,
resetHideTimer,
isNearButtons,
handleMouseEnter,
handleMouseLeave,
right,
showRightTopics
])
return ( return (
<> <NavigationContainer
<TriggerArea $right={right} onMouseEnter={() => setIsVisible(true)} onMouseLeave={() => resetHideTimer()} /> $isVisible={isVisible}
<NavigationContainer $isVisible={isVisible} $right={right}> $right={right}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<ButtonGroup> <ButtonGroup>
<Tooltip title={t('chat.navigation.prev')} placement="left"> <Tooltip title={t('chat.navigation.prev')} placement="left">
<NavigationButton <NavigationButton
@ -184,7 +271,6 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
icon={<UpOutlined />} icon={<UpOutlined />}
onClick={handlePrevMessage} onClick={handlePrevMessage}
aria-label={t('chat.navigation.prev')} aria-label={t('chat.navigation.prev')}
onMouseLeave={() => resetHideTimer()}
/> />
</Tooltip> </Tooltip>
<Divider /> <Divider />
@ -194,24 +280,13 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
icon={<DownOutlined />} icon={<DownOutlined />}
onClick={handleNextMessage} onClick={handleNextMessage}
aria-label={t('chat.navigation.next')} aria-label={t('chat.navigation.next')}
onMouseLeave={() => resetHideTimer()}
/> />
</Tooltip> </Tooltip>
</ButtonGroup> </ButtonGroup>
</NavigationContainer> </NavigationContainer>
</>
) )
} }
const TriggerArea = styled.div<{ $right: string }>`
position: fixed;
right: ${(props) => props.$right};
top: 40%;
width: 20px;
height: 20%;
z-index: 998;
`
interface NavigationContainerProps { interface NavigationContainerProps {
$isVisible: boolean $isVisible: boolean
$right: string $right: string
@ -228,12 +303,6 @@ const NavigationContainer = styled.div<NavigationContainerProps>`
transform 0.3s ease-in-out, transform 0.3s ease-in-out,
opacity 0.3s ease-in-out; opacity 0.3s ease-in-out;
pointer-events: ${(props) => (props.$isVisible ? 'auto' : 'none')}; pointer-events: ${(props) => (props.$isVisible ? 'auto' : 'none')};
&:hover {
transform: translateY(-50%) translateX(0);
opacity: 1;
pointer-events: auto;
}
` `
const ButtonGroup = styled.div` const ButtonGroup = styled.div`