mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 20:12:38 +08:00
feat: add drag-and-drop multi-selection
This commit is contained in:
parent
6292ced495
commit
cf1d980735
@ -89,7 +89,7 @@ const MultiSelectActionPopup: FC<MultiSelectActionPopupProps> = ({ visible, onCl
|
||||
<ActionButton danger icon={<DeleteOutlined />} onClick={() => handleAction('delete')} />
|
||||
</Tooltip>
|
||||
</ActionButtons>
|
||||
<Tooltip title={t('popup.close')}>
|
||||
<Tooltip title={t('chat.navigation.close')}>
|
||||
<ActionButton icon={<CloseOutlined />} onClick={handleClose} />
|
||||
</Tooltip>
|
||||
</ActionBar>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||
import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
|
||||
import MultiSelectActionPopup from '@renderer/components/Popups/MultiSelectionPopup'
|
||||
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -12,7 +12,7 @@ import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newM
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Flex, Modal } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import React, { FC, useMemo, useEffect, useState } from 'react'
|
||||
import React, { FC, useEffect, useMemo, useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
@ -44,7 +44,7 @@ const Chat: FC<Props> = (props) => {
|
||||
// 获取所有消息块
|
||||
const messageBlocks = useSelector(messageBlocksSelectors.selectEntities)
|
||||
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const mainRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentSearchRef = React.useRef<ContentSearchRef>(null)
|
||||
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
|
||||
|
||||
@ -118,7 +118,7 @@ const Chat: FC<Props> = (props) => {
|
||||
setTimeout(() => (firstUpdateCompleted = true), 300)
|
||||
firstUpdateOrNoFirstUpdateHandler()
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleMultiSelect = (value: boolean) => {
|
||||
setIsMultiSelectMode(value)
|
||||
|
||||
@ -21,6 +21,7 @@ interface Props {
|
||||
isMultiSelectMode?: boolean // 添加是否处于多选模式
|
||||
selectedMessages?: Set<string> // 已选择的消息ID集合
|
||||
onSelectMessage?: (messageId: string, selected: boolean) => void // 消息选择回调
|
||||
registerMessageElement?: (id: string, element: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
const MessageGroup = ({
|
||||
@ -29,7 +30,8 @@ const MessageGroup = ({
|
||||
hidePresetMessages,
|
||||
isMultiSelectMode = false,
|
||||
selectedMessages = new Set(),
|
||||
onSelectMessage
|
||||
onSelectMessage,
|
||||
registerMessageElement
|
||||
}: Props) => {
|
||||
const { editMessage } = useMessageOperations(topic)
|
||||
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
|
||||
@ -193,7 +195,8 @@ const MessageGroup = ({
|
||||
messageId={message.id}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
isSelected={selectedMessages.has(message.id)}
|
||||
onSelect={(selected) => onSelectMessage?.(message.id, selected)}>
|
||||
onSelect={(selected) => onSelectMessage?.(message.id, selected)}
|
||||
registerElement={registerMessageElement}>
|
||||
{messageContent}
|
||||
</SelectableMessage>
|
||||
)
|
||||
|
||||
@ -262,7 +262,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
{
|
||||
label: t('chat.multiple.select'),
|
||||
key: 'multi-select',
|
||||
icon: <MenuOutlined />,
|
||||
icon: <MenuOutlined size={16} />,
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, true)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Checkbox } from 'antd'
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { FC, ReactNode, useEffect, useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface SelectableMessageProps {
|
||||
@ -8,11 +8,29 @@ interface SelectableMessageProps {
|
||||
isSelected: boolean
|
||||
onSelect: (selected: boolean) => void
|
||||
messageId: string
|
||||
registerElement?: (id: string, element: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
const SelectableMessage: FC<SelectableMessageProps> = ({ children, isMultiSelectMode, isSelected, onSelect }) => {
|
||||
const SelectableMessage: FC<SelectableMessageProps> = ({
|
||||
children,
|
||||
isMultiSelectMode,
|
||||
isSelected,
|
||||
onSelect,
|
||||
messageId,
|
||||
registerElement
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (registerElement && containerRef.current) {
|
||||
registerElement(messageId, containerRef.current)
|
||||
return () => registerElement(messageId, null)
|
||||
}
|
||||
return undefined
|
||||
}, [messageId, registerElement])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container ref={containerRef}>
|
||||
{isMultiSelectMode && (
|
||||
<CheckboxWrapper>
|
||||
<Checkbox checked={isSelected} onChange={(e) => onSelect(e.target.checked)} />
|
||||
|
||||
@ -57,6 +57,10 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
|
||||
const [isProcessingContext, setIsProcessingContext] = useState(false)
|
||||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 })
|
||||
const messageElements = useRef<Map<string, HTMLElement>>(new Map())
|
||||
const messages = useTopicMessages(topic.id)
|
||||
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
|
||||
const messagesRef = useRef<Message[]>(messages)
|
||||
@ -65,6 +69,91 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
const handleSelectMessage = useCallback((messageId: string, selected: boolean) => {
|
||||
setSelectedMessages((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (selected) {
|
||||
newSet.add(messageId)
|
||||
} else {
|
||||
newSet.delete(messageId)
|
||||
}
|
||||
EventEmitter.emit(EVENT_NAMES.SELECTED_MESSAGES_CHANGED, Array.from(newSet))
|
||||
return newSet
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMultiSelectMode) return
|
||||
|
||||
const updateDragPos = (e: MouseEvent) => {
|
||||
const container = containerRef.current!
|
||||
const rect = container.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left + container.scrollLeft
|
||||
const y = e.clientY - rect.top + container.scrollTop
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.ant-checkbox-wrapper')) return
|
||||
setIsDragging(true)
|
||||
const pos = updateDragPos(e)
|
||||
setDragStart(pos)
|
||||
setDragCurrent(pos)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
setDragCurrent(updateDragPos(e))
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDragging) return
|
||||
|
||||
// 计算选择框的边界
|
||||
const left = Math.min(dragStart.x, dragCurrent.x)
|
||||
const right = Math.max(dragStart.x, dragCurrent.x)
|
||||
const top = Math.min(dragStart.y, dragCurrent.y)
|
||||
const bottom = Math.max(dragStart.y, dragCurrent.y)
|
||||
|
||||
// 选择在框内的消息
|
||||
messageElements.current.forEach((element, messageId) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
// 检查消息元素是否与选择框相交
|
||||
const isIntersecting = !(rect.right < left || rect.left > right || rect.bottom < top || rect.top > bottom)
|
||||
|
||||
if (isIntersecting) {
|
||||
handleSelectMessage(messageId, true)
|
||||
}
|
||||
})
|
||||
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
container.addEventListener('mousedown', handleMouseDown)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('mousedown', handleMouseDown)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isMultiSelectMode, isDragging, dragStart, dragCurrent, handleSelectMessage])
|
||||
|
||||
const registerMessageElement = useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
messageElements.current.set(id, element)
|
||||
} else {
|
||||
messageElements.current.delete(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleMultiSelect = (value: boolean) => {
|
||||
setIsMultiSelectMode(value)
|
||||
@ -93,19 +182,6 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleSelectMessage = (messageId: string, selected: boolean) => {
|
||||
setSelectedMessages((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (selected) {
|
||||
newSet.add(messageId)
|
||||
} else {
|
||||
newSet.delete(messageId)
|
||||
}
|
||||
EventEmitter.emit(EVENT_NAMES.SELECTED_MESSAGES_CHANGED, Array.from(newSet))
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
|
||||
setDisplayMessages(newDisplayMessages)
|
||||
@ -296,15 +372,19 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => onComponentUpdate?.())
|
||||
}, [])
|
||||
}, [onComponentUpdate])
|
||||
|
||||
const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
|
||||
return (
|
||||
<Container
|
||||
id="messages"
|
||||
style={{ maxWidth, paddingTop: showPrompt ? 10 : 0 }}
|
||||
key={assistant.id}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
maxWidth,
|
||||
paddingTop: showPrompt ? 10 : 0,
|
||||
}}
|
||||
key={assistant.id}
|
||||
$right={topicPosition === 'left'}>
|
||||
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
|
||||
<InfiniteScroll
|
||||
@ -325,6 +405,7 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedMessages={selectedMessages}
|
||||
onSelectMessage={handleSelectMessage}
|
||||
registerMessageElement={registerMessageElement}
|
||||
/>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
@ -338,6 +419,16 @@ const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onCompo
|
||||
</NarrowLayout>
|
||||
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
|
||||
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
|
||||
{isDragging && isMultiSelectMode && (
|
||||
<SelectionBox
|
||||
style={{
|
||||
left: Math.min(dragStart.x, dragCurrent.x),
|
||||
top: Math.min(dragStart.y, dragCurrent.y),
|
||||
width: Math.abs(dragCurrent.x - dragStart.x),
|
||||
height: Math.abs(dragCurrent.y - dragStart.y),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -405,4 +496,12 @@ const Container = styled(Scrollbar)<ContainerProps>`
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const SelectionBox = styled.div`
|
||||
position: absolute;
|
||||
border: 1px dashed var(--color-primary);
|
||||
background-color: rgba(0, 114, 245, 0.1);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
`
|
||||
|
||||
export default Messages
|
||||
|
||||
Loading…
Reference in New Issue
Block a user