diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx new file mode 100644 index 0000000000..02b9b3eafd --- /dev/null +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -0,0 +1,91 @@ +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { Dropdown } from 'antd' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface ContextMenuProps { + children: React.ReactNode + onContextMenu?: (e: React.MouseEvent) => void +} + +const ContextMenu: React.FC = ({ children, onContextMenu }) => { + const { t } = useTranslation() + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) + const [selectedQuoteText, setSelectedQuoteText] = useState('') + const [selectedText, setSelectedText] = useState('') + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + const _selectedText = window.getSelection()?.toString() + if (_selectedText) { + const quotedText = + _selectedText + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + '\n-------------' + setSelectedQuoteText(quotedText) + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setSelectedText(_selectedText) + } + onContextMenu?.(e) + }, + [onContextMenu] + ) + + useEffect(() => { + const handleClick = () => { + setContextMenuPosition(null) + } + document.addEventListener('click', handleClick) + return () => { + document.removeEventListener('click', handleClick) + } + }, []) + + // 获取右键菜单项 + const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [ + { + key: 'copy', + label: t('common.copy'), + onClick: () => { + if (selectedText) { + navigator.clipboard + .writeText(selectedText) + .then(() => { + window.message.success({ content: t('message.copied'), key: 'copy-message' }) + }) + .catch(() => { + window.message.error({ content: t('message.copy.failed'), key: 'copy-message-failed' }) + }) + } + } + }, + { + key: 'quote', + label: t('chat.message.quote'), + onClick: () => { + if (selectedQuoteText) { + EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) + } + } + } + ] + + return ( +
+ {contextMenuPosition && ( + +
+ + )} + {children} +
+ ) +} + +export default ContextMenu diff --git a/src/renderer/src/pages/home/Messages/CitationsList.tsx b/src/renderer/src/pages/home/Messages/CitationsList.tsx index 230767a3a8..ea2ad062aa 100644 --- a/src/renderer/src/pages/home/Messages/CitationsList.tsx +++ b/src/renderer/src/pages/home/Messages/CitationsList.tsx @@ -1,3 +1,4 @@ +import ContextMenu from '@renderer/components/ContextMenu' import Favicon from '@renderer/components/Icons/FallbackFavicon' import { HStack } from '@renderer/components/Layout' import { fetchWebContent } from '@renderer/utils/fetch' @@ -136,36 +137,44 @@ const WebSearchCitation: React.FC<{ citation: Citation }> = ({ citation }) => { return ( - - {citation.showFavicon && citation.url && ( - + + + {citation.showFavicon && citation.url && ( + + )} + handleLinkClick(citation.url, e)}> + {citation.title || {citation.hostname}} + + {fetchedContent && } + + {isLoading ? ( + + ) : ( + {fetchedContent} )} - handleLinkClick(citation.url, e)}> - {citation.title || {citation.hostname}} - - {fetchedContent && } - - {isLoading ? ( - - ) : ( - {fetchedContent} - )} + ) } -const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => ( - - - {citation.showFavicon && } - handleLinkClick(citation.url, e)}> - {citation.title} - - {citation.content && } - - {citation.content && truncateText(citation.content, 100)} - -) +const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => { + return ( + + + + {citation.showFavicon && } + handleLinkClick(citation.url, e)}> + {citation.title} + + {citation.content && } + + + {citation.content && truncateText(citation.content, 100)} + + + + ) +} const OpenButton = styled(Button)` display: flex; @@ -237,6 +246,7 @@ const WebSearchCard = styled.div` border-radius: var(--list-item-border-radius); background-color: var(--color-background); transition: all 0.3s ease; + position: relative; ` const WebSearchCardHeader = styled.div` @@ -252,6 +262,15 @@ const WebSearchCardContent = styled.div` font-size: 13px; line-height: 1.6; color: var(--color-text-2); + user-select: text; + cursor: text; + + &.selectable-text { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + } ` export default CitationsList diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 6e8334d50a..9ae2da00b2 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -1,3 +1,4 @@ +import ContextMenu from '@renderer/components/ContextMenu' import { FONT_FAMILY } from '@renderer/config/constant' import { useAssistant } from '@renderer/hooks/useAssistant' import { useModel } from '@renderer/hooks/useModel' @@ -8,8 +9,8 @@ import { getModelUniqId } from '@renderer/services/ModelService' import { Assistant, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' import { classNames } from '@renderer/utils' -import { Divider, Dropdown } from 'antd' -import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Divider } from 'antd' +import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -49,10 +50,6 @@ const MessageItem: FC = ({ const { showMessageDivider, messageFont, fontSize } = useSettings() const messageContainerRef = useRef(null) - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null) - const [selectedQuoteText, setSelectedQuoteText] = useState('') - const [selectedText, setSelectedText] = useState('') - const isLastMessage = index === 0 const isAssistantMessage = message.role === 'assistant' const showMenubar = !isStreaming && !message.status.includes('ing') @@ -64,31 +61,6 @@ const MessageItem: FC = ({ const messageBorder = showMessageDivider ? undefined : 'none' const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage) - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault() - const _selectedText = window.getSelection()?.toString() - if (_selectedText) { - const quotedText = - _selectedText - .split('\n') - .map((line) => `> ${line}`) - .join('\n') + '\n-------------' - setSelectedQuoteText(quotedText) - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setSelectedText(_selectedText) - } - }, []) - - useEffect(() => { - const handleClick = () => { - setContextMenuPosition(null) - } - document.addEventListener('click', handleClick) - return () => { - document.removeEventListener('click', handleClick) - } - }, []) - const messageHighlightHandler = useCallback((highlight: boolean = true) => { if (messageContainerRef.current) { messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) @@ -130,46 +102,38 @@ const MessageItem: FC = ({ 'message-user': !isAssistantMessage })} ref={messageContainerRef} - onContextMenu={handleContextMenu} style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}> - {contextMenuPosition && ( - -
- - )} - - - - - - {showMenubar && ( - - - } - setModel={setModel} - /> - - )} - + + + + + + + {showMenubar && ( + + + } + setModel={setModel} + /> + + )} + + ) } @@ -182,24 +146,6 @@ const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolea : undefined } -const getContextMenuItems = (t: (key: string) => string, selectedQuoteText: string, selectedText: string) => [ - { - key: 'copy', - label: t('common.copy'), - onClick: () => { - navigator.clipboard.writeText(selectedText) - window.message.success({ content: t('message.copied'), key: 'copy-message' }) - } - }, - { - key: 'quote', - label: t('chat.message.quote'), - onClick: () => { - EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) - } - } -] - const MessageContainer = styled.div` display: flex; flex-direction: column;