diff --git a/src/renderer/src/hooks/useScrollPosition.ts b/src/renderer/src/hooks/useScrollPosition.ts index acb1bd851b..c0f09300d8 100644 --- a/src/renderer/src/hooks/useScrollPosition.ts +++ b/src/renderer/src/hooks/useScrollPosition.ts @@ -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(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 } } diff --git a/src/renderer/src/hooks/useTimer.ts b/src/renderer/src/hooks/useTimer.ts index af4df045cf..69fa89cdf9 100644 --- a/src/renderer/src/hooks/useTimer.ts +++ b/src/renderer/src/hooks/useTimer.ts @@ -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()) const intervalMapRef = useRef(new Map()) + /** + * 清除指定 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) => { - clearTimeout(timeoutMapRef.current.get(key)) - const timer = setTimeout(...args) - timeoutMapRef.current.set(key, timer) - return () => clearTimeoutTimer(key) - } + const setTimeoutTimer = useCallback( + (key: string, ...args: Parameters) => { + 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) => { - 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) => { + 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, diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx index 4aeb275b6a..9f9f92f25c 100644 --- a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -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 = ({ 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 = () => { diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 798559f8d9..ddbcd9cf25 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -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 = ({ 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 = ({ 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', diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx index d36448913f..ab489dd700 100644 --- a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -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 = ({ 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 = ({ messages }) => { return } - messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) + scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' }) }, [setSelectedMessage] ) diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 1e1eca27a1..849e4b1c76 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -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' }) } } } diff --git a/src/renderer/src/pages/home/Messages/MessageOutline.tsx b/src/renderer/src/pages/home/Messages/MessageOutline.tsx index 1327dd4a89..0fb372841f 100644 --- a/src/renderer/src/pages/home/Messages/MessageOutline.tsx +++ b/src/renderer/src/pages/home/Messages/MessageOutline.tsx @@ -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 = ({ message }) => { const parent = messageOutlineContainerRef.current?.parentElement const messageContentContainer = parent?.querySelector('.message-content-container') if (messageContentContainer) { - const headingElement = messageContentContainer.querySelector(`#${id}`) + const headingElement = messageContentContainer.querySelector(`#${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' }) } } } diff --git a/src/renderer/src/utils/dom.ts b/src/renderer/src/utils/dom.ts index 6dd09cda5e..15161ea86d 100644 --- a/src/renderer/src/utils/dom.ts +++ b/src/renderer/src/utils/dom.ts @@ -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',