mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 14:41:24 +08:00
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:
parent
706fac898a
commit
03ff6e1ca6
@ -1,5 +1,5 @@
|
|||||||
import { throttle } from 'lodash'
|
import { throttle } from 'lodash'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { useTimer } from './useTimer'
|
import { useTimer } from './useTimer'
|
||||||
|
|
||||||
@ -12,13 +12,18 @@ import { useTimer } from './useTimer'
|
|||||||
*/
|
*/
|
||||||
export default function useScrollPosition(key: string, throttleWait?: number) {
|
export default function useScrollPosition(key: string, throttleWait?: number) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const scrollKey = `scroll:${key}`
|
const scrollKey = useMemo(() => `scroll:${key}`, [key])
|
||||||
|
const scrollKeyRef = useRef(scrollKey)
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollKeyRef.current = scrollKey
|
||||||
|
}, [scrollKey])
|
||||||
|
|
||||||
const handleScroll = throttle(() => {
|
const handleScroll = throttle(() => {
|
||||||
const position = containerRef.current?.scrollTop ?? 0
|
const position = containerRef.current?.scrollTop ?? 0
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
window.keyv.set(scrollKey, position)
|
window.keyv.set(scrollKeyRef.current, position)
|
||||||
})
|
})
|
||||||
}, throttleWait ?? 100)
|
}, throttleWait ?? 100)
|
||||||
|
|
||||||
@ -28,5 +33,9 @@ export default function useScrollPosition(key: string, throttleWait?: number) {
|
|||||||
setTimeoutTimer('scrollEffect', scroll, 50)
|
setTimeoutTimer('scrollEffect', scroll, 50)
|
||||||
}, [scrollKey, setTimeoutTimer])
|
}, [scrollKey, setTimeoutTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => handleScroll.cancel()
|
||||||
|
}, [handleScroll])
|
||||||
|
|
||||||
return { containerRef, handleScroll }
|
return { containerRef, handleScroll }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器
|
* 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器
|
||||||
@ -43,10 +43,38 @@ export const useTimer = () => {
|
|||||||
const timeoutMapRef = useRef(new Map<string, NodeJS.Timeout>())
|
const timeoutMapRef = useRef(new Map<string, NodeJS.Timeout>())
|
||||||
const intervalMapRef = 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(() => {
|
useEffect(() => {
|
||||||
return () => clearAllTimers()
|
return () => clearAllTimers()
|
||||||
}, [])
|
}, [clearAllTimers])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置一个 setTimeout 定时器
|
* 设置一个 setTimeout 定时器
|
||||||
@ -65,12 +93,15 @@ export const useTimer = () => {
|
|||||||
* cleanup();
|
* cleanup();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
const setTimeoutTimer = (key: string, ...args: Parameters<typeof setTimeout>) => {
|
const setTimeoutTimer = useCallback(
|
||||||
clearTimeout(timeoutMapRef.current.get(key))
|
(key: string, ...args: Parameters<typeof setTimeout>) => {
|
||||||
const timer = setTimeout(...args)
|
clearTimeout(timeoutMapRef.current.get(key))
|
||||||
timeoutMapRef.current.set(key, timer)
|
const timer = setTimeout(...args)
|
||||||
return () => clearTimeoutTimer(key)
|
timeoutMapRef.current.set(key, timer)
|
||||||
}
|
return () => clearTimeoutTimer(key)
|
||||||
|
},
|
||||||
|
[clearTimeoutTimer]
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置一个 setInterval 定时器
|
* 设置一个 setInterval 定时器
|
||||||
@ -89,56 +120,31 @@ export const useTimer = () => {
|
|||||||
* cleanup();
|
* cleanup();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
const setIntervalTimer = (key: string, ...args: Parameters<typeof setInterval>) => {
|
const setIntervalTimer = useCallback(
|
||||||
clearInterval(intervalMapRef.current.get(key))
|
(key: string, ...args: Parameters<typeof setInterval>) => {
|
||||||
const timer = setInterval(...args)
|
clearInterval(intervalMapRef.current.get(key))
|
||||||
intervalMapRef.current.set(key, timer)
|
const timer = setInterval(...args)
|
||||||
return () => clearIntervalTimer(key)
|
intervalMapRef.current.set(key, timer)
|
||||||
}
|
return () => clearIntervalTimer(key)
|
||||||
|
},
|
||||||
/**
|
[clearIntervalTimer]
|
||||||
* 清除指定 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有 setTimeout 定时器
|
* 清除所有 setTimeout 定时器
|
||||||
*/
|
*/
|
||||||
const clearAllTimeoutTimers = () => {
|
const clearAllTimeoutTimers = useCallback(() => {
|
||||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||||
timeoutMapRef.current.clear()
|
timeoutMapRef.current.clear()
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有 setInterval 定时器
|
* 清除所有 setInterval 定时器
|
||||||
*/
|
*/
|
||||||
const clearAllIntervalTimers = () => {
|
const clearAllIntervalTimers = useCallback(() => {
|
||||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||||
intervalMapRef.current.clear()
|
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 {
|
return {
|
||||||
setTimeoutTimer,
|
setTimeoutTimer,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
|||||||
import { useTimer } from '@renderer/hooks/useTimer'
|
import { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import type { RootState } from '@renderer/store'
|
import type { RootState } from '@renderer/store'
|
||||||
// import { selectCurrentTopicId } from '@renderer/store/newMessage'
|
// import { selectCurrentTopicId } from '@renderer/store/newMessage'
|
||||||
|
import { scrollIntoView } from '@renderer/utils/dom'
|
||||||
import { Button, Drawer, Tooltip } from 'antd'
|
import { Button, Drawer, Tooltip } from 'antd'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@ -118,7 +119,8 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollToMessage = (element: HTMLElement) => {
|
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 = () => {
|
const scrollToTop = () => {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { estimateMessageUsage } from '@renderer/services/TokenService'
|
|||||||
import type { Assistant, Topic } from '@renderer/types'
|
import type { Assistant, Topic } from '@renderer/types'
|
||||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||||
import { classNames, cn } from '@renderer/utils'
|
import { classNames, cn } from '@renderer/utils'
|
||||||
|
import { scrollIntoView } from '@renderer/utils/dom'
|
||||||
import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
|
import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
|
||||||
import { Divider } from 'antd'
|
import { Divider } from 'antd'
|
||||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
@ -79,9 +80,10 @@ const MessageItem: FC<Props> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && messageContainerRef.current) {
|
if (isEditing && messageContainerRef.current) {
|
||||||
messageContainerRef.current.scrollIntoView({
|
scrollIntoView(messageContainerRef.current, {
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'center'
|
block: 'center',
|
||||||
|
container: 'nearest'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [isEditing])
|
}, [isEditing])
|
||||||
@ -124,7 +126,7 @@ const MessageItem: FC<Props> = ({
|
|||||||
const messageHighlightHandler = useCallback(
|
const messageHighlightHandler = useCallback(
|
||||||
(highlight: boolean = true) => {
|
(highlight: boolean = true) => {
|
||||||
if (messageContainerRef.current) {
|
if (messageContainerRef.current) {
|
||||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
|
scrollIntoView(messageContainerRef.current, { behavior: 'smooth', block: 'center', container: 'nearest' })
|
||||||
if (highlight) {
|
if (highlight) {
|
||||||
setTimeoutTimer(
|
setTimeoutTimer(
|
||||||
'messageHighlightHandler',
|
'messageHighlightHandler',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { newMessagesActions } from '@renderer/store/newMessage'
|
|||||||
// import { updateMessageThunk } from '@renderer/store/thunk/messageThunk'
|
// import { updateMessageThunk } from '@renderer/store/thunk/messageThunk'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
||||||
|
import { scrollIntoView } from '@renderer/utils/dom'
|
||||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||||
import { Avatar } from 'antd'
|
import { Avatar } from 'antd'
|
||||||
import { CircleChevronDown } from 'lucide-react'
|
import { CircleChevronDown } from 'lucide-react'
|
||||||
@ -119,7 +120,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
|||||||
() => {
|
() => {
|
||||||
const messageElement = document.getElementById(`message-${message.id}`)
|
const messageElement = document.getElementById(`message-${message.id}`)
|
||||||
if (messageElement) {
|
if (messageElement) {
|
||||||
messageElement.scrollIntoView({ behavior: 'auto', block: 'start' })
|
scrollIntoView(messageElement, { behavior: 'auto', block: 'start', container: 'nearest' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
100
|
100
|
||||||
@ -141,7 +142,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||||
},
|
},
|
||||||
[setSelectedMessage]
|
[setSelectedMessage]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { MultiModelMessageStyle } from '@renderer/store/settings'
|
|||||||
import type { Topic } from '@renderer/types'
|
import type { Topic } from '@renderer/types'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
|
import { scrollIntoView } from '@renderer/utils/dom'
|
||||||
import { Popover } from 'antd'
|
import { Popover } from 'antd'
|
||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
import { memo, useCallback, useEffect, useMemo, useState } 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}`)
|
const messageElement = document.getElementById(`message-${message.id}`)
|
||||||
if (messageElement) {
|
if (messageElement) {
|
||||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
200
|
200
|
||||||
@ -132,7 +133,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
|||||||
setSelectedMessage(message)
|
setSelectedMessage(message)
|
||||||
} else {
|
} 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 { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||||
import type { Message } from '@renderer/types/newMessage'
|
import type { Message } from '@renderer/types/newMessage'
|
||||||
import { MessageBlockType } from '@renderer/types/newMessage'
|
import { MessageBlockType } from '@renderer/types/newMessage'
|
||||||
|
import { scrollIntoView } from '@renderer/utils/dom'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import React, { useMemo, useRef } from 'react'
|
import React, { useMemo, useRef } from 'react'
|
||||||
import { useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
@ -72,10 +73,10 @@ const MessageOutline: FC<MessageOutlineProps> = ({ message }) => {
|
|||||||
const parent = messageOutlineContainerRef.current?.parentElement
|
const parent = messageOutlineContainerRef.current?.parentElement
|
||||||
const messageContentContainer = parent?.querySelector('.message-content-container')
|
const messageContentContainer = parent?.querySelector('.message-content-container')
|
||||||
if (messageContentContainer) {
|
if (messageContentContainer) {
|
||||||
const headingElement = messageContentContainer.querySelector(`#${id}`)
|
const headingElement = messageContentContainer.querySelector<HTMLElement>(`#${id}`)
|
||||||
if (headingElement) {
|
if (headingElement) {
|
||||||
const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start'
|
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.
|
* Simple wrapper for scrollIntoView with common default options.
|
||||||
* Provides a unified interface with sensible defaults.
|
* Provides a unified interface with sensible defaults.
|
||||||
@ -5,7 +17,12 @@
|
|||||||
* @param element - The target element to scroll into view
|
* @param element - The target element to scroll into view
|
||||||
* @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' }
|
* @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 = {
|
const defaultOptions: ScrollIntoViewOptions = {
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'center',
|
block: 'center',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user