refactor(Scrollbar): Optimize scroll handling logic to support external scroll events (#6047)

* refactor(Scrollbar): Optimize scroll handling logic to support external scroll events

- Refactor `onScroll` logic to support external scroll events
- Integrate with `useScrollPosition` hook for better scroll state management
- Memorize the scoll state for better user experience
- Fix type definition for `ref` attribute
- Remove unnecessary `ref` type overrides
- Improve component compatibility and maintainability

* perf(useScrollPosition): Optimize scroll position updates using requestAnimationFrame

- Wrap the `window.keyv.set` call in `requestAnimationFrame` to reduce unnecessary performance overhead and improve responsiveness during scrolling.

* fix(Messages): Remove unused FC imports and add onComponentUpdate and onFirstUpdate properties
This commit is contained in:
jwcrystal 2025-05-19 16:33:06 +08:00 committed by GitHub
parent e3f5999362
commit 0c32ac1262
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 40 additions and 20 deletions

View File

@ -2,12 +2,13 @@ import { throttle } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props extends React.HTMLAttributes<HTMLDivElement> { interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onScroll'> {
right?: boolean right?: boolean
ref?: any ref?: React.RefObject<HTMLDivElement | null>
onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll
} }
const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject<HTMLDivElement | null> }) => { const Scrollbar: FC<Props> = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => {
const [isScrolling, setIsScrolling] = useState(false) const [isScrolling, setIsScrolling] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null) const timeoutRef = useRef<NodeJS.Timeout | null>(null)
@ -21,18 +22,31 @@ const Scrollbar: FC<Props> = ({ ref, ...props }: Props & { ref?: React.RefObject
timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500) timeoutRef.current = setTimeout(() => setIsScrolling(false), 1500)
}, []) }, [])
const throttledHandleScroll = throttle(handleScroll, 200) const throttledInternalScrollHandler = throttle(handleScroll, 200)
// Combined scroll handler
const combinedOnScroll = useCallback(() => {
// Event is available if needed by internal handler
throttledInternalScrollHandler() // Call internal logic
if (externalOnScroll) {
externalOnScroll() // Call external logic (from useScrollPosition)
}
}, [throttledInternalScrollHandler, externalOnScroll])
useEffect(() => { useEffect(() => {
return () => { return () => {
timeoutRef.current && clearTimeout(timeoutRef.current) timeoutRef.current && clearTimeout(timeoutRef.current)
throttledHandleScroll.cancel() throttledInternalScrollHandler.cancel()
} }
}, [throttledHandleScroll]) }, [throttledInternalScrollHandler])
return ( return (
<Container {...props} isScrolling={isScrolling} onScroll={throttledHandleScroll} ref={ref}> <Container
{props.children} {...htmlProps} // Pass other HTML attributes
isScrolling={isScrolling}
onScroll={combinedOnScroll} // Use the combined handler
ref={passedRef}>
{children}
</Container> </Container>
) )
} }

View File

@ -7,7 +7,9 @@ export default function useScrollPosition(key: string) {
const handleScroll = throttle(() => { const handleScroll = throttle(() => {
const position = containerRef.current?.scrollTop ?? 0 const position = containerRef.current?.scrollTop ?? 0
window.keyv.set(scrollKey, position) window.requestAnimationFrame(() => {
window.keyv.set(scrollKey, position)
})
}, 100) }, 100)
useEffect(() => { useEffect(() => {

View File

@ -3,6 +3,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
import { LOAD_MORE_COUNT } from '@renderer/config/constant' import { LOAD_MORE_COUNT } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations' import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShortcut } from '@renderer/hooks/useShortcuts'
import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic' import { autoRenameTopic, getTopic } from '@renderer/hooks/useTopic'
@ -26,7 +27,7 @@ import { updateCodeBlock } from '@renderer/utils/markdown'
import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { isTextLikeBlock } from '@renderer/utils/messageUtils/is' import { isTextLikeBlock } from '@renderer/utils/messageUtils/is'
import { last } from 'lodash' import { last } from 'lodash'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component' import InfiniteScroll from 'react-infinite-scroll-component'
import styled from 'styled-components' import styled from 'styled-components'
@ -45,12 +46,14 @@ interface MessagesProps {
onFirstUpdate?(): void onFirstUpdate?(): void
} }
const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => { const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => {
const { containerRef: scrollContainerRef, handleScroll: handleScrollPosition } = useScrollPosition(
`topic-${topic.id}`
)
const { t } = useTranslation() const { t } = useTranslation()
const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
const { updateTopic, addTopic } = useAssistant(assistant.id) const { updateTopic, addTopic } = useAssistant(assistant.id)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const containerRef = useRef<HTMLDivElement>(null)
const [displayMessages, setDisplayMessages] = useState<Message[]>([]) const [displayMessages, setDisplayMessages] = useState<Message[]>([])
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false)
@ -77,16 +80,16 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
}, [showAssistants, showTopics, topicPosition]) }, [showAssistants, showTopics, topicPosition])
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
if (containerRef.current) { if (scrollContainerRef.current) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (containerRef.current) { if (scrollContainerRef.current) {
containerRef.current.scrollTo({ scrollContainerRef.current.scrollTo({
top: containerRef.current.scrollHeight top: scrollContainerRef.current.scrollHeight
}) })
} }
}) })
} }
}, []) }, [scrollContainerRef])
const clearTopic = useCallback( const clearTopic = useCallback(
async (data: Topic) => { async (data: Topic) => {
@ -120,14 +123,14 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
}) })
}), }),
EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => { EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => {
await captureScrollableDivAsBlob(containerRef, async (blob) => { await captureScrollableDivAsBlob(scrollContainerRef, async (blob) => {
if (blob) { if (blob) {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
} }
}) })
}), }),
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => { EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
const imageData = await captureScrollableDivAsDataURL(containerRef) const imageData = await captureScrollableDivAsDataURL(scrollContainerRef)
if (imageData) { if (imageData) {
window.api.file.saveImage(removeSpecialCharactersForFileName(topic.name), imageData) window.api.file.saveImage(removeSpecialCharactersForFileName(topic.name), imageData)
} }
@ -261,7 +264,8 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
id="messages" id="messages"
style={{ maxWidth, paddingTop: showPrompt ? 10 : 0 }} style={{ maxWidth, paddingTop: showPrompt ? 10 : 0 }}
key={assistant.id} key={assistant.id}
ref={containerRef} ref={scrollContainerRef}
onScroll={handleScrollPosition}
$right={topicPosition === 'left'}> $right={topicPosition === 'left'}>
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}> <NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
<InfiniteScroll <InfiniteScroll