From cf1d980735af0b943cc1b114b53cd299b0e3be29 Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Sun, 18 May 2025 00:12:26 +0800 Subject: [PATCH] feat: add drag-and-drop multi-selection --- .../components/Popups/MultiSelectionPopup.tsx | 2 +- src/renderer/src/pages/home/Chat.tsx | 8 +- .../src/pages/home/Messages/MessageGroup.tsx | 7 +- .../pages/home/Messages/MessageMenubar.tsx | 2 +- .../src/pages/home/Messages/MessageSelect.tsx | 24 +++- .../src/pages/home/Messages/Messages.tsx | 131 +++++++++++++++--- 6 files changed, 147 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx index f5e6377673..dce5d756f0 100644 --- a/src/renderer/src/components/Popups/MultiSelectionPopup.tsx +++ b/src/renderer/src/components/Popups/MultiSelectionPopup.tsx @@ -89,7 +89,7 @@ const MultiSelectActionPopup: FC = ({ visible, onCl } onClick={() => handleAction('delete')} /> - + } onClick={handleClose} /> diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index ee5262644d..e2e15581ab 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -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) => { // 获取所有消息块 const messageBlocks = useSelector(messageBlocksSelectors.selectEntities) - const mainRef = React.useRef(null) + const mainRef = React.useRef(null) const contentSearchRef = React.useRef(null) const [filterIncludeUser, setFilterIncludeUser] = useState(false) @@ -118,7 +118,7 @@ const Chat: FC = (props) => { setTimeout(() => (firstUpdateCompleted = true), 300) firstUpdateOrNoFirstUpdateHandler() } - + useEffect(() => { const handleToggleMultiSelect = (value: boolean) => { setIsMultiSelectMode(value) diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index 56bb8eb548..35493e414c 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -21,6 +21,7 @@ interface Props { isMultiSelectMode?: boolean // 添加是否处于多选模式 selectedMessages?: Set // 已选择的消息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} ) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index ac2a60843e..1cd2a4c76e 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -262,7 +262,7 @@ const MessageMenubar: FC = (props) => { { label: t('chat.multiple.select'), key: 'multi-select', - icon: , + icon: , onClick: () => { EventEmitter.emit(EVENT_NAMES.MESSAGE_MULTI_SELECT, true) } diff --git a/src/renderer/src/pages/home/Messages/MessageSelect.tsx b/src/renderer/src/pages/home/Messages/MessageSelect.tsx index f982cf67ff..42817be480 100644 --- a/src/renderer/src/pages/home/Messages/MessageSelect.tsx +++ b/src/renderer/src/pages/home/Messages/MessageSelect.tsx @@ -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 = ({ children, isMultiSelectMode, isSelected, onSelect }) => { +const SelectableMessage: FC = ({ + children, + isMultiSelectMode, + isSelected, + onSelect, + messageId, + registerElement +}) => { + const containerRef = useRef(null) + + useEffect(() => { + if (registerElement && containerRef.current) { + registerElement(messageId, containerRef.current) + return () => registerElement(messageId, null) + } + return undefined + }, [messageId, registerElement]) + return ( - + {isMultiSelectMode && ( onSelect(e.target.checked)} /> diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index f5c4048f8e..086607d4c4 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -57,6 +57,10 @@ const Messages: FC = ({ assistant, topic, setActiveTopic, onCompo const [isProcessingContext, setIsProcessingContext] = useState(false) const [selectedMessages, setSelectedMessages] = useState>(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>(new Map()) const messages = useTopicMessages(topic.id) const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic) const messagesRef = useRef(messages) @@ -65,6 +69,91 @@ const Messages: FC = ({ 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 = ({ 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 = ({ assistant, topic, setActiveTopic, onCompo useEffect(() => { requestAnimationFrame(() => onComponentUpdate?.()) - }, []) + }, [onComponentUpdate]) const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages]) return ( = ({ assistant, topic, setActiveTopic, onCompo isMultiSelectMode={isMultiSelectMode} selectedMessages={selectedMessages} onSelectMessage={handleSelectMessage} + registerMessageElement={registerMessageElement} /> ))} {isLoadingMore && ( @@ -338,6 +419,16 @@ const Messages: FC = ({ assistant, topic, setActiveTopic, onCompo {messageNavigation === 'anchor' && } {messageNavigation === 'buttons' && } + {isDragging && isMultiSelectMode && ( + + )} ) } @@ -405,4 +496,12 @@ const Container = styled(Scrollbar)` 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