feat: add citation list right-click copy (#6066)

* feat: add citation list right-click copy

* fix: extract the right-click menu as a component
This commit is contained in:
自由的世界人 2025-05-17 00:22:09 +08:00 committed by GitHub
parent cd1fd700ce
commit 807099f231
3 changed files with 169 additions and 113 deletions

View File

@ -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<ContextMenuProps> = ({ children, onContextMenu }) => {
const { t } = useTranslation()
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string>('')
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 (
<div onContextMenu={handleContextMenu} style={{ width: '100%' }}>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
open={true}
trigger={['contextMenu']}>
<div />
</Dropdown>
)}
{children}
</div>
)
}
export default ContextMenu

View File

@ -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 (
<WebSearchCard>
<WebSearchCardHeader>
{citation.showFavicon && citation.url && (
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
<ContextMenu>
<WebSearchCardHeader>
{citation.showFavicon && citation.url && (
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
)}
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title || <span className="hostname">{citation.hostname}</span>}
</CitationLink>
{fetchedContent && <CopyButton content={fetchedContent} />}
</WebSearchCardHeader>
{isLoading ? (
<Skeleton active paragraph={{ rows: 1 }} title={false} />
) : (
<WebSearchCardContent className="selectable-text">{fetchedContent}</WebSearchCardContent>
)}
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title || <span className="hostname">{citation.hostname}</span>}
</CitationLink>
{fetchedContent && <CopyButton content={fetchedContent} />}
</WebSearchCardHeader>
{isLoading ? (
<Skeleton active paragraph={{ rows: 1 }} title={false} />
) : (
<WebSearchCardContent>{fetchedContent}</WebSearchCardContent>
)}
</ContextMenu>
</WebSearchCard>
)
}
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => (
<WebSearchCard>
<WebSearchCardHeader>
{citation.showFavicon && <FileSearch width={16} />}
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title}
</CitationLink>
{citation.content && <CopyButton content={citation.content} />}
</WebSearchCardHeader>
<WebSearchCardContent>{citation.content && truncateText(citation.content, 100)}</WebSearchCardContent>
</WebSearchCard>
)
const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
return (
<WebSearchCard>
<ContextMenu>
<WebSearchCardHeader>
{citation.showFavicon && <FileSearch width={16} />}
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
{citation.title}
</CitationLink>
{citation.content && <CopyButton content={citation.content} />}
</WebSearchCardHeader>
<WebSearchCardContent className="selectable-text">
{citation.content && truncateText(citation.content, 100)}
</WebSearchCardContent>
</ContextMenu>
</WebSearchCard>
)
}
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

View File

@ -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<Props> = ({
const { showMessageDivider, messageFont, fontSize } = useSettings()
const messageContainerRef = useRef<HTMLDivElement>(null)
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string>('')
const isLastMessage = index === 0
const isAssistantMessage = message.role === 'assistant'
const showMenubar = !isStreaming && !message.status.includes('ing')
@ -64,31 +61,6 @@ const MessageItem: FC<Props> = ({
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<Props> = ({
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}
onContextMenu={handleContextMenu}
style={{ ...style, alignItems: isBubbleStyle ? (isAssistantMessage ? 'start' : 'end') : undefined }}>
{contextMenuPosition && (
<Dropdown
overlayStyle={{ left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText) }}
open={true}
trigger={['contextMenu']}>
<div />
</Dropdown>
)}
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
<MessageContentContainer
className="message-content-container"
style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
{showMenubar && (
<MessageFooter
style={{
border: messageBorder,
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
}}>
<MessageTokens message={message} isLastMessage={isLastMessage} />
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
/>
</MessageFooter>
)}
</MessageContentContainer>
<ContextMenu>
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
<MessageContentContainer
className="message-content-container"
style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
{showMenubar && (
<MessageFooter
style={{
border: messageBorder,
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
}}>
<MessageTokens message={message} isLastMessage={isLastMessage} />
<MessageMenubar
message={message}
assistant={assistant}
model={model}
index={index}
topic={topic}
isLastMessage={isLastMessage}
isAssistantMessage={isAssistantMessage}
isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
/>
</MessageFooter>
)}
</MessageContentContainer>
</ContextMenu>
</MessageContainer>
)
}
@ -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;