From 66115ca3066366fadd39b334896c463876b97a8b Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 11 Sep 2025 16:56:37 +0800 Subject: [PATCH] refactor: update Scrollbar component and integrate horizontal scrolling in TabContainer and KnowledgeBaseInput (#9988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: update Scrollbar component and integrate horizontal scrolling in TabContainer and KnowledgeBaseInput - Renamed Props interface to ScrollbarProps for clarity. - Implemented useHorizontalScroll hook in TabContainer to manage horizontal scrolling. - Removed deprecated scroll handling logic and replaced it with the new hook. - Enhanced KnowledgeBaseInput to utilize horizontal scrolling for better UI management. - Cleaned up unused imports and components for improved code maintainability. * refactor: update dependencies type in useHorizontalScroll hook to readonly unknown[] for better type safety * feat: add scrollDistance parameter to useHorizontalScroll hook for customizable scrolling behavior * refactor: replace useHorizontalScroll with HorizontalScrollContainer in TabContainer, KnowledgeBaseInput, and MentionModelsInput components - Updated TabContainer to utilize HorizontalScrollContainer for improved scrolling functionality. - Refactored KnowledgeBaseInput and MentionModelsInput to replace the custom horizontal scroll implementation with HorizontalScrollContainer, simplifying the code and enhancing maintainability. * refactor(HorizontalScrollContainer): remove paddingRight prop and update scroll handling - Removed the unused paddingRight prop from HorizontalScrollContainerProps and its implementation. - Updated handleScrollRight to accept the event parameter and stop propagation. - Simplified the Container styled component by eliminating the padding-right style. * fix: sync issue * fix: isLeftNavbar inputbar display issue * feat(HorizontalScrollContainer): add scroll end detection and disable button hover effect --------- Co-authored-by: 自由的世界人 <3196812536@qq.com> --- .../HorizontalScrollContainer/index.tsx | 179 ++++++++++++++++++ .../src/components/QuickPanel/provider.tsx | 7 + .../src/components/QuickPanel/types.ts | 1 + .../src/components/Scrollbar/index.tsx | 4 +- .../src/components/Tab/TabContainer.tsx | 145 ++++---------- src/renderer/src/pages/home/Chat.tsx | 5 +- .../src/pages/home/Inputbar/Inputbar.tsx | 24 +++ .../home/Inputbar/KnowledgeBaseButton.tsx | 8 + .../home/Inputbar/KnowledgeBaseInput.tsx | 26 +-- .../home/Inputbar/MentionModelsButton.tsx | 8 + .../home/Inputbar/MentionModelsInput.tsx | 44 +++++ 11 files changed, 326 insertions(+), 125 deletions(-) create mode 100644 src/renderer/src/components/HorizontalScrollContainer/index.tsx create mode 100644 src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx diff --git a/src/renderer/src/components/HorizontalScrollContainer/index.tsx b/src/renderer/src/components/HorizontalScrollContainer/index.tsx new file mode 100644 index 0000000000..ed5cdc52de --- /dev/null +++ b/src/renderer/src/components/HorizontalScrollContainer/index.tsx @@ -0,0 +1,179 @@ +import Scrollbar from '@renderer/components/Scrollbar' +import { ChevronRight } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import styled from 'styled-components' + +/** + * 水平滚动容器 + * @param children 子元素 + * @param dependencies 依赖项 + * @param scrollDistance 滚动距离 + * @param className 类名 + * @param gap 间距 + * @param expandable 是否可展开 + */ +export interface HorizontalScrollContainerProps { + children: React.ReactNode + dependencies?: readonly unknown[] + scrollDistance?: number + className?: string + gap?: string + expandable?: boolean +} + +const HorizontalScrollContainer: React.FC = ({ + children, + dependencies = [], + scrollDistance = 200, + className, + gap = '8px', + expandable = false +}) => { + const scrollRef = useRef(null) + const [canScroll, setCanScroll] = useState(false) + const [isExpanded, setIsExpanded] = useState(false) + const [isScrolledToEnd, setIsScrolledToEnd] = useState(false) + + const handleScrollRight = (event: React.MouseEvent) => { + scrollRef.current?.scrollBy({ left: scrollDistance, behavior: 'smooth' }) + event.stopPropagation() + } + + const handleContainerClick = (e: React.MouseEvent) => { + if (expandable) { + // 确保不是点击了其他交互元素(如 tag 的关闭按钮) + const target = e.target as HTMLElement + if (!target.closest('[data-no-expand]')) { + setIsExpanded(!isExpanded) + } + } + } + + const checkScrollability = () => { + const scrollElement = scrollRef.current + if (scrollElement) { + const parentElement = scrollElement.parentElement + const availableWidth = parentElement ? parentElement.clientWidth : scrollElement.clientWidth + + // 确保容器不会超出可用宽度 + const canScrollValue = scrollElement.scrollWidth > Math.min(availableWidth, scrollElement.clientWidth) + setCanScroll(canScrollValue) + + // 检查是否滚动到最右侧 + if (canScrollValue) { + const isAtEnd = Math.abs(scrollElement.scrollLeft + scrollElement.clientWidth - scrollElement.scrollWidth) <= 1 + setIsScrolledToEnd(isAtEnd) + } else { + setIsScrolledToEnd(false) + } + } + } + + useEffect(() => { + const scrollElement = scrollRef.current + if (!scrollElement) return + + checkScrollability() + + const handleScroll = () => { + checkScrollability() + } + + const resizeObserver = new ResizeObserver(checkScrollability) + resizeObserver.observe(scrollElement) + + scrollElement.addEventListener('scroll', handleScroll) + window.addEventListener('resize', checkScrollability) + + return () => { + resizeObserver.disconnect() + scrollElement.removeEventListener('scroll', handleScroll) + window.removeEventListener('resize', checkScrollability) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies) + + return ( + + + {children} + + {canScroll && !isExpanded && !isScrolledToEnd && ( + + + + )} + + ) +} + +const Container = styled.div<{ $expandable?: boolean; $disableHoverButton?: boolean }>` + display: flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + max-width: 100%; + position: relative; + cursor: ${(props) => (props.$expandable ? 'pointer' : 'default')}; + + ${(props) => + !props.$disableHoverButton && + ` + &:hover { + .scroll-right-button { + opacity: 1; + } + } + `} +` + +const ScrollContent = styled(Scrollbar)<{ + $gap: string + $isExpanded?: boolean + $expandable?: boolean +}>` + display: flex; + overflow-x: ${(props) => (props.$expandable && props.$isExpanded ? 'hidden' : 'auto')}; + overflow-y: hidden; + white-space: ${(props) => (props.$expandable && props.$isExpanded ? 'normal' : 'nowrap')}; + gap: ${(props) => props.$gap}; + flex-wrap: ${(props) => (props.$expandable && props.$isExpanded ? 'wrap' : 'nowrap')}; + + &::-webkit-scrollbar { + display: none; + } +` + +const ScrollButton = styled.div` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + z-index: 1; + opacity: 0; + transition: opacity 0.2s ease-in-out; + cursor: pointer; + background: var(--color-background); + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + 0 6px 16px 0 rgba(0, 0, 0, 0.08), + 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 9px 28px 8px rgba(0, 0, 0, 0.05); + color: var(--color-text-2); + + &:hover { + color: var(--color-text); + background: var(--color-list-item); + } +` + +export default HorizontalScrollContainer diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx index c06d337248..85224e7a39 100644 --- a/src/renderer/src/components/QuickPanel/provider.tsx +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -32,6 +32,11 @@ export const QuickPanelProvider: React.FC = ({ children setList((prevList) => prevList.map((item) => (item === targetItem ? { ...item, isSelected } : item))) }, []) + // 添加更新整个列表的方法 + const updateList = useCallback((newList: QuickPanelListItem[]) => { + setList(newList) + }, []) + const open = useCallback((options: QuickPanelOpenOptions) => { if (clearTimer.current) { clearTimeout(clearTimer.current) @@ -85,6 +90,7 @@ export const QuickPanelProvider: React.FC = ({ children open, close, updateItemSelection, + updateList, isVisible, symbol, @@ -103,6 +109,7 @@ export const QuickPanelProvider: React.FC = ({ children open, close, updateItemSelection, + updateList, isVisible, symbol, list, diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index d8e2ff26b0..030eb6f1f4 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -68,6 +68,7 @@ export interface QuickPanelContextType { readonly open: (options: QuickPanelOpenOptions) => void readonly close: (action?: QuickPanelCloseAction, searchText?: string) => void readonly updateItemSelection: (targetItem: QuickPanelListItem, isSelected: boolean) => void + readonly updateList: (newList: QuickPanelListItem[]) => void readonly isVisible: boolean readonly symbol: string readonly list: QuickPanelListItem[] diff --git a/src/renderer/src/components/Scrollbar/index.tsx b/src/renderer/src/components/Scrollbar/index.tsx index 60258d8c8b..e50e128d50 100644 --- a/src/renderer/src/components/Scrollbar/index.tsx +++ b/src/renderer/src/components/Scrollbar/index.tsx @@ -2,12 +2,12 @@ import { throttle } from 'lodash' import { FC, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' -interface Props extends Omit, 'onScroll'> { +export interface ScrollbarProps extends Omit, 'onScroll'> { ref?: React.Ref onScroll?: () => void // Custom onScroll prop for useScrollPosition's handleScroll } -const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => { +const Scrollbar: FC = ({ ref: passedRef, children, onScroll: externalOnScroll, ...htmlProps }) => { const [isScrolling, setIsScrolling] = useState(false) const timeoutRef = useRef(null) diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 4f7d9f13d6..efa19565d1 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -1,6 +1,6 @@ import { PlusOutlined } from '@ant-design/icons' import { Sortable, useDndReorder } from '@renderer/components/dnd' -import Scrollbar from '@renderer/components/Scrollbar' +import HorizontalScrollContainer from '@renderer/components/HorizontalScrollContainer' import { isMac } from '@renderer/config/constant' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' import { useTheme } from '@renderer/context/ThemeProvider' @@ -14,9 +14,8 @@ import type { Tab } from '@renderer/store/tabs' import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs' import { ThemeMode } from '@renderer/types' import { classNames } from '@renderer/utils' -import { Button, Tooltip } from 'antd' +import { Tooltip } from 'antd' import { - ChevronRight, FileSearch, Folder, Hammer, @@ -33,7 +32,7 @@ import { Terminal, X } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import styled from 'styled-components' @@ -98,8 +97,6 @@ const TabsContainer: React.FC = ({ children }) => { const { hideMinappPopup } = useMinappPopup() const { minapps } = useMinapps() const { t } = useTranslation() - const scrollRef = useRef(null) - const [canScroll, setCanScroll] = useState(false) const getTabId = (path: string): string => { if (path === '/') return 'home' @@ -175,31 +172,6 @@ const TabsContainer: React.FC = ({ children }) => { navigate(tab.path) } - const handleScrollRight = () => { - scrollRef.current?.scrollBy({ left: 200, behavior: 'smooth' }) - } - - useEffect(() => { - const scrollElement = scrollRef.current - if (!scrollElement) return - - const checkScrollability = () => { - setCanScroll(scrollElement.scrollWidth > scrollElement.clientWidth) - } - - checkScrollability() - - const resizeObserver = new ResizeObserver(checkScrollability) - resizeObserver.observe(scrollElement) - - window.addEventListener('resize', checkScrollability) - - return () => { - resizeObserver.disconnect() - window.removeEventListener('resize', checkScrollability) - } - }, [tabs]) - const visibleTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs]) const { onSortEnd } = useDndReorder({ @@ -212,46 +184,39 @@ const TabsContainer: React.FC = ({ children }) => { return ( - - - ( - handleTabClick(tab)}> - - {tab.id && {getTabIcon(tab.id, minapps)}} - {getTabTitle(tab.id)} - - {tab.id !== 'home' && ( - { - e.stopPropagation() - closeTab(tab.id) - }}> - - - )} - - )} - /> - - {canScroll && ( - - - - )} + + ( + handleTabClick(tab)}> + + {tab.id && {getTabIcon(tab.id, minapps)}} + {getTabTitle(tab.id)} + + {tab.id !== 'home' && ( + { + e.stopPropagation() + closeTab(tab.id) + }}> + + + )} + + )} + /> - + ` z-index: 1; -webkit-app-region: no-drag; } -` -const TabsArea = styled.div` - display: flex; - align-items: center; - flex: 1 1 auto; - min-width: 0; - gap: 6px; - padding-right: 2rem; - position: relative; + .tab-scroll-container { + -webkit-app-region: drag; - -webkit-app-region: drag; - - > * { - -webkit-app-region: no-drag; - } - - &:hover { - .scroll-right-button { - opacity: 1; + > * { + -webkit-app-region: no-drag; } } ` -const TabsScroll = styled(Scrollbar)` - &::-webkit-scrollbar { - display: none; - } -` - const Tab = styled.div<{ active?: boolean }>` display: flex; align-items: center; @@ -414,22 +359,6 @@ const AddTabButton = styled.div` } ` -const ScrollButton = styled(Button)` - position: absolute; - right: 4rem; - top: 50%; - transform: translateY(-50%); - z-index: 1; - opacity: 0; - transition: opacity 0.2s ease-in-out; - - border: none; - box-shadow: - 0 6px 16px 0 rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12), - 0 9px 28px 8px rgba(0, 0, 0, 0.05); -` - const RightButtonsContainer = styled.div` display: flex; align-items: center; diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 68fa4e5dc0..4ea6bcdd57 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -43,6 +43,7 @@ const Chat: FC = (props) => { const { showTopics } = useShowTopics() const { isMultiSelectMode } = useChatContext(props.activeTopic) const { isTopNavbar } = useNavbarPosition() + const chatMaxWidth = useChatMaxWidth() const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) @@ -153,7 +154,7 @@ const Chat: FC = (props) => { vertical flex={1} justify="space-between" - style={{ maxWidth: '100%', height: mainHeight }}> + style={{ maxWidth: chatMaxWidth, height: mainHeight }}> = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(bases ?? []) } + const handleRemoveModel = (model: Model) => { + setMentionedModels(mentionedModels.filter((m) => m.id !== model.id)) + } + + const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => { + const newKnowledgeBases = assistant.knowledge_bases?.filter((kb) => kb.id !== knowledgeBase.id) + updateAssistant({ + ...assistant, + knowledge_bases: newKnowledgeBases + }) + setSelectedKnowledgeBases(newKnowledgeBases ?? []) + } + const onEnableGenerateImage = () => { updateAssistant({ ...assistant, enableGenerateImage: !assistant.enableGenerateImage }) } @@ -851,6 +866,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')} ref={containerRef}> {files.length > 0 && } + {selectedKnowledgeBases.length > 0 && ( + + )} + {mentionedModels.length > 0 && ( + + )}