From a090984c67bfecbba2ba3c951edaaf10f2ba8689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= Date: Mon, 10 Mar 2025 17:48:48 +0800 Subject: [PATCH] feat: Add chat navigation button --- src/renderer/src/i18n/locales/en-us.json | 8 +- src/renderer/src/i18n/locales/ja-jp.json | 8 +- src/renderer/src/i18n/locales/ru-ru.json | 8 +- src/renderer/src/i18n/locales/zh-cn.json | 8 +- src/renderer/src/i18n/locales/zh-tw.json | 8 +- .../pages/home/Messages/ChatNavigation.tsx | 258 ++++++++++++++++++ .../src/pages/home/Messages/Messages.tsx | 2 + 7 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/pages/home/Messages/ChatNavigation.tsx diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index da3ae557..e6b1b731 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -170,7 +170,13 @@ "topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic", "topics.title": "Topics", "topics.unpinned": "Unpinned Topics", - "translate": "Translate" + "translate": "Translate", + "navigation": { + "prev": "Previous Message", + "next": "Next Message", + "first": "Already at the first message", + "last": "Already at the last message" + } }, "code_block": { "collapse": "Collapse", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 271d790c..4054ddfa 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -170,7 +170,13 @@ "topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供", "topics.title": "トピック", "topics.unpinned": "固定解除", - "translate": "翻訳" + "translate": "翻訳", + "navigation": { + "prev": "前のメッセージ", + "next": "次のメッセージ", + "first": "最初のメッセージです", + "last": "最後のメッセージです" + } }, "code_block": { "collapse": "折りたたむ", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 57c4671e..1b46e911 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -170,7 +170,13 @@ "topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы", "topics.title": "Топики", "topics.unpinned": "Открепленные темы", - "translate": "Перевести" + "translate": "Перевести", + "navigation": { + "prev": "Предыдущее сообщение", + "next": "Следующее сообщение", + "first": "Уже первое сообщение", + "last": "Уже последнее сообщение" + } }, "code_block": { "collapse": "Свернуть", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0904dad6..e088b396 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -170,7 +170,13 @@ "topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词", "topics.title": "话题", "topics.unpinned": "取消固定", - "translate": "翻译" + "translate": "翻译", + "navigation": { + "prev": "上一条消息", + "next": "下一条消息", + "first": "已经是第一条消息", + "last": "已经是最后一条消息" + } }, "code_block": { "collapse": "收起", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 13a6c21e..66ce6a74 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -170,7 +170,13 @@ "topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞", "topics.title": "話題", "topics.unpinned": "取消固定", - "translate": "翻譯" + "translate": "翻譯", + "navigation": { + "prev": "上一條訊息", + "next": "下一條訊息", + "first": "已經是第一條訊息", + "last": "已經是最後一條訊息" + } }, "code_block": { "collapse": "折疊", diff --git a/src/renderer/src/pages/home/Messages/ChatNavigation.tsx b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx new file mode 100644 index 00000000..5dc51d9d --- /dev/null +++ b/src/renderer/src/pages/home/Messages/ChatNavigation.tsx @@ -0,0 +1,258 @@ +import { DownOutlined, UpOutlined } from '@ant-design/icons' +import { Button, message, Tooltip } from 'antd' +import { FC, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface ChatNavigationProps { + containerId: string +} + +const ChatNavigation: FC = ({ containerId }) => { + const { t } = useTranslation() + const [isVisible, setIsVisible] = useState(false) + const [hideTimer, setHideTimer] = useState(null) + + const resetHideTimer = () => { + if (hideTimer) { + clearTimeout(hideTimer) + } + setIsVisible(true) + const timer = setTimeout(() => { + setIsVisible(false) + }, 1000) + setHideTimer(timer) + } + + const findUserMessages = () => { + const container = document.getElementById(containerId) + if (!container) return [] + + const userMessages = Array.from(container.getElementsByClassName('message-user')) + return userMessages as HTMLElement[] + } + + const findAssistantMessages = () => { + const container = document.getElementById(containerId) + if (!container) return [] + + const assistantMessages = Array.from(container.getElementsByClassName('message-assistant')) + return assistantMessages as HTMLElement[] + } + + const scrollToMessage = (element: HTMLElement) => { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + const getCurrentVisibleIndex = (direction: 'up' | 'down') => { + const userMessages = findUserMessages() + const assistantMessages = findAssistantMessages() + const container = document.getElementById(containerId) + if (!container) return -1 + + const containerRect = container.getBoundingClientRect() + const visibleThreshold = containerRect.height * 0.1 + + let visibleIndices: number[] = [] + + for (let i = 0; i < userMessages.length; i++) { + const messageRect = userMessages[i].getBoundingClientRect() + const visibleHeight = + Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top) + if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) { + visibleIndices.push(i) + } + } + + if (visibleIndices.length > 0) { + return direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices) + } + + visibleIndices = [] + for (let i = 0; i < assistantMessages.length; i++) { + const messageRect = assistantMessages[i].getBoundingClientRect() + const visibleHeight = + Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top) + if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) { + visibleIndices.push(i) + } + } + + if (visibleIndices.length > 0) { + const assistantIndex = direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices) + return assistantIndex < userMessages.length ? assistantIndex : userMessages.length - 1 + } + + return -1 + } + + const handleNextMessage = () => { + resetHideTimer() + const userMessages = findUserMessages() + const assistantMessages = findAssistantMessages() + if (userMessages.length === 0 && assistantMessages.length === 0) { + message.info(t('chat.navigation.last')) + return + } + + const visibleIndex = getCurrentVisibleIndex('down') + if (visibleIndex === -1) { + message.info(t('chat.navigation.last')) + return + } + + const targetIndex = visibleIndex - 1 + + if (targetIndex < 0) { + message.info(t('chat.navigation.last')) + return + } + + scrollToMessage(userMessages[targetIndex]) + } + + const handlePrevMessage = () => { + resetHideTimer() + const userMessages = findUserMessages() + const assistantMessages = findAssistantMessages() + if (userMessages.length === 0 && assistantMessages.length === 0) { + message.info(t('chat.navigation.first')) + return + } + + const visibleIndex = getCurrentVisibleIndex('up') + if (visibleIndex === -1) { + message.info(t('chat.navigation.first')) + return + } + + const targetIndex = visibleIndex + 1 + + if (targetIndex >= userMessages.length) { + message.info(t('chat.navigation.first')) + return + } + + scrollToMessage(userMessages[targetIndex]) + } + + useEffect(() => { + const container = document.getElementById(containerId) + if (!container) return + + const handleScroll = () => { + setIsVisible(true) + resetHideTimer() + } + + container.addEventListener('scroll', handleScroll) + return () => { + container.removeEventListener('scroll', handleScroll) + if (hideTimer) { + clearTimeout(hideTimer) + } + } + }, [containerId, hideTimer, resetHideTimer]) + + return ( + <> + setIsVisible(true)} onMouseLeave={() => resetHideTimer()} /> + + + + } + onClick={handlePrevMessage} + aria-label={t('chat.navigation.prev')} + onMouseLeave={() => resetHideTimer()} + /> + + + + } + onClick={handleNextMessage} + aria-label={t('chat.navigation.next')} + onMouseLeave={() => resetHideTimer()} + /> + + + + + ) +} + +const TriggerArea = styled.div` + position: fixed; + right: 0; + top: 40%; + width: 20px; + height: 20%; + z-index: 998; + background: transparent; +` + +interface NavigationContainerProps { + $isVisible: boolean +} + +const NavigationContainer = styled.div` + position: fixed; + right: 16px; + top: 50%; + transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')}); + z-index: 999; + opacity: ${(props) => (props.$isVisible ? 1 : 0)}; + transition: + transform 0.3s ease-in-out, + opacity 0.3s ease-in-out; + pointer-events: ${(props) => (props.$isVisible ? 'auto' : 'none')}; + + &:hover { + transform: translateY(-50%) translateX(0); + opacity: 1; + pointer-events: auto; + } +` + +const ButtonGroup = styled.div` + display: flex; + flex-direction: column; + background: var(--bg-color); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + backdrop-filter: blur(8px); + border: 1px solid var(--color-border); +` + +const NavigationButton = styled(Button)` + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0; + border: none; + color: var(--color-text); + transition: all 0.2s ease-in-out; + + &:hover { + background-color: var(--color-hover); + color: var(--color-primary); + } + + .anticon { + font-size: 14px; + } +` + +const Divider = styled.div` + height: 1px; + background: var(--color-border); + margin: 0; +` + +export default ChatNavigation diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 3b9d801b..a804c601 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -31,6 +31,7 @@ import InfiniteScroll from 'react-infinite-scroll-component' import BeatLoader from 'react-spinners/BeatLoader' import styled from 'styled-components' +import ChatNavigation from './ChatNavigation' import MessageGroup from './MessageGroup' import NarrowLayout from './NarrowLayout' import Prompt from './Prompt' @@ -254,6 +255,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) + ) }