feat: add drag-and-drop multi-selection

This commit is contained in:
Pleasurecruise 2025-05-18 00:12:26 +08:00
parent 6292ced495
commit cf1d980735
No known key found for this signature in database
GPG Key ID: E6385136096279B6
6 changed files with 147 additions and 27 deletions

View File

@ -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>

View File

@ -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)

View File

@ -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>
)

View File

@ -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)
}

View File

@ -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)} />

View File

@ -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