fix: stabilize home scroll behavior (#11576)
* feat(dom): extend scrollIntoView with Chromium-specific options Add ChromiumScrollIntoViewOptions interface to support additional scroll container options * refactor(hooks): optimize timer and scroll position hooks - Use useMemo for scrollKey in useScrollPosition to avoid unnecessary recalculations - Refactor useTimer to use useCallback for all functions to prevent unnecessary recreations - Reorganize function order and improve cleanup logic in useTimer * fix: stabilize home scroll behavior * Update src/renderer/src/pages/home/Messages/ChatNavigation.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(utils/dom): add null check for element in scrollIntoView Prevent potential runtime errors by gracefully handling falsy elements with a warning log * fix(hooks): use ref for scroll key to avoid stale closure * fix(useScrollPosition): add cleanup for scroll handler to prevent memory leaks --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { throttle } from 'lodash'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useTimer } from './useTimer'
|
||||
|
||||
@@ -12,13 +12,18 @@ import { useTimer } from './useTimer'
|
||||
*/
|
||||
export default function useScrollPosition(key: string, throttleWait?: number) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollKey = `scroll:${key}`
|
||||
const scrollKey = useMemo(() => `scroll:${key}`, [key])
|
||||
const scrollKeyRef = useRef(scrollKey)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
useEffect(() => {
|
||||
scrollKeyRef.current = scrollKey
|
||||
}, [scrollKey])
|
||||
|
||||
const handleScroll = throttle(() => {
|
||||
const position = containerRef.current?.scrollTop ?? 0
|
||||
window.requestAnimationFrame(() => {
|
||||
window.keyv.set(scrollKey, position)
|
||||
window.keyv.set(scrollKeyRef.current, position)
|
||||
})
|
||||
}, throttleWait ?? 100)
|
||||
|
||||
@@ -28,5 +33,9 @@ export default function useScrollPosition(key: string, throttleWait?: number) {
|
||||
setTimeoutTimer('scrollEffect', scroll, 50)
|
||||
}, [scrollKey, setTimeoutTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => handleScroll.cancel()
|
||||
}, [handleScroll])
|
||||
|
||||
return { containerRef, handleScroll }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器
|
||||
@@ -43,10 +43,38 @@ export const useTimer = () => {
|
||||
const timeoutMapRef = useRef(new Map<string, NodeJS.Timeout>())
|
||||
const intervalMapRef = useRef(new Map<string, NodeJS.Timeout>())
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setTimeout 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearTimeoutTimer = useCallback((key: string) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
timeoutMapRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setInterval 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearIntervalTimer = useCallback((key: string) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
intervalMapRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 清除所有定时器,包括 setTimeout 和 setInterval
|
||||
*/
|
||||
const clearAllTimers = useCallback(() => {
|
||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||
timeoutMapRef.current.clear()
|
||||
intervalMapRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// 组件卸载时自动清理所有定时器
|
||||
useEffect(() => {
|
||||
return () => clearAllTimers()
|
||||
}, [])
|
||||
}, [clearAllTimers])
|
||||
|
||||
/**
|
||||
* 设置一个 setTimeout 定时器
|
||||
@@ -65,12 +93,15 @@ export const useTimer = () => {
|
||||
* cleanup();
|
||||
* ```
|
||||
*/
|
||||
const setTimeoutTimer = (key: string, ...args: Parameters<typeof setTimeout>) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
const timer = setTimeout(...args)
|
||||
timeoutMapRef.current.set(key, timer)
|
||||
return () => clearTimeoutTimer(key)
|
||||
}
|
||||
const setTimeoutTimer = useCallback(
|
||||
(key: string, ...args: Parameters<typeof setTimeout>) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
const timer = setTimeout(...args)
|
||||
timeoutMapRef.current.set(key, timer)
|
||||
return () => clearTimeoutTimer(key)
|
||||
},
|
||||
[clearTimeoutTimer]
|
||||
)
|
||||
|
||||
/**
|
||||
* 设置一个 setInterval 定时器
|
||||
@@ -89,56 +120,31 @@ export const useTimer = () => {
|
||||
* cleanup();
|
||||
* ```
|
||||
*/
|
||||
const setIntervalTimer = (key: string, ...args: Parameters<typeof setInterval>) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
const timer = setInterval(...args)
|
||||
intervalMapRef.current.set(key, timer)
|
||||
return () => clearIntervalTimer(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setTimeout 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearTimeoutTimer = (key: string) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
timeoutMapRef.current.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setInterval 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearIntervalTimer = (key: string) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
intervalMapRef.current.delete(key)
|
||||
}
|
||||
const setIntervalTimer = useCallback(
|
||||
(key: string, ...args: Parameters<typeof setInterval>) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
const timer = setInterval(...args)
|
||||
intervalMapRef.current.set(key, timer)
|
||||
return () => clearIntervalTimer(key)
|
||||
},
|
||||
[clearIntervalTimer]
|
||||
)
|
||||
|
||||
/**
|
||||
* 清除所有 setTimeout 定时器
|
||||
*/
|
||||
const clearAllTimeoutTimers = () => {
|
||||
const clearAllTimeoutTimers = useCallback(() => {
|
||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||
timeoutMapRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 清除所有 setInterval 定时器
|
||||
*/
|
||||
const clearAllIntervalTimers = () => {
|
||||
const clearAllIntervalTimers = useCallback(() => {
|
||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||
intervalMapRef.current.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有定时器,包括 setTimeout 和 setInterval
|
||||
*/
|
||||
const clearAllTimers = () => {
|
||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||
timeoutMapRef.current.clear()
|
||||
intervalMapRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
setTimeoutTimer,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import type { RootState } from '@renderer/store'
|
||||
// import { selectCurrentTopicId } from '@renderer/store/newMessage'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { Button, Drawer, Tooltip } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -118,7 +119,8 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
}
|
||||
|
||||
const scrollToMessage = (element: HTMLElement) => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// Use container: 'nearest' to keep scroll within the chat pane (Chromium-only, see #11565, #11567)
|
||||
scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { classNames, cn } from '@renderer/utils'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
|
||||
import { Divider } from 'antd'
|
||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||
@@ -79,9 +80,10 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({
|
||||
scrollIntoView(messageContainerRef.current, {
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
block: 'center',
|
||||
container: 'nearest'
|
||||
})
|
||||
}
|
||||
}, [isEditing])
|
||||
@@ -124,7 +126,7 @@ const MessageItem: FC<Props> = ({
|
||||
const messageHighlightHandler = useCallback(
|
||||
(highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
scrollIntoView(messageContainerRef.current, { behavior: 'smooth', block: 'center', container: 'nearest' })
|
||||
if (highlight) {
|
||||
setTimeoutTimer(
|
||||
'messageHighlightHandler',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
// import { updateMessageThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { Avatar } from 'antd'
|
||||
import { CircleChevronDown } from 'lucide-react'
|
||||
@@ -119,7 +120,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'auto', block: 'start' })
|
||||
scrollIntoView(messageElement, { behavior: 'auto', block: 'start', container: 'nearest' })
|
||||
}
|
||||
},
|
||||
100
|
||||
@@ -141,7 +142,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
return
|
||||
}
|
||||
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
},
|
||||
[setSelectedMessage]
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { Popover } from 'antd'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -73,7 +74,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
}
|
||||
},
|
||||
200
|
||||
@@ -132,7 +133,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
setSelectedMessage(message)
|
||||
} else {
|
||||
// 直接滚动
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -72,10 +73,10 @@ const MessageOutline: FC<MessageOutlineProps> = ({ message }) => {
|
||||
const parent = messageOutlineContainerRef.current?.parentElement
|
||||
const messageContentContainer = parent?.querySelector('.message-content-container')
|
||||
if (messageContentContainer) {
|
||||
const headingElement = messageContentContainer.querySelector(`#${id}`)
|
||||
const headingElement = messageContentContainer.querySelector<HTMLElement>(`#${id}`)
|
||||
if (headingElement) {
|
||||
const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start'
|
||||
headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock })
|
||||
scrollIntoView(headingElement, { behavior: 'smooth', block: scrollBlock, container: 'nearest' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('utils/dom')
|
||||
|
||||
interface ChromiumScrollIntoViewOptions extends ScrollIntoViewOptions {
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#container
|
||||
* @see https://github.com/microsoft/TypeScript/issues/62803
|
||||
*/
|
||||
container?: 'all' | 'nearest'
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple wrapper for scrollIntoView with common default options.
|
||||
* Provides a unified interface with sensible defaults.
|
||||
@@ -5,7 +17,12 @@
|
||||
* @param element - The target element to scroll into view
|
||||
* @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' }
|
||||
*/
|
||||
export function scrollIntoView(element: HTMLElement, options?: ScrollIntoViewOptions): void {
|
||||
export function scrollIntoView(element: HTMLElement, options?: ChromiumScrollIntoViewOptions): void {
|
||||
if (!element) {
|
||||
logger.warn('[scrollIntoView] Unexpected falsy element. Do nothing as fallback.')
|
||||
return
|
||||
}
|
||||
|
||||
const defaultOptions: ScrollIntoViewOptions = {
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
|
||||
Reference in New Issue
Block a user