feat: add multi-select mode wrapper for message component (#8653)

* feat: add multi-select mode wrapper for message component

* fix: update

* update

* update

* chore: minor updates

* fix: add drag threshold
This commit is contained in:
Konv Suu 2025-07-31 21:06:22 +08:00 committed by GitHub
parent 10b7c70a59
commit 0113447481
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 108 additions and 58 deletions

View File

@ -2,6 +2,7 @@ import { loggerService } from '@logger'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
import { useModel } from '@renderer/hooks/useModel' import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
@ -38,6 +39,16 @@ interface Props {
const logger = loggerService.withContext('MessageItem') const logger = loggerService.withContext('MessageItem')
const WrapperContainer = ({
isMultiSelectMode,
children
}: {
isMultiSelectMode: boolean
children: React.ReactNode
}) => {
return isMultiSelectMode ? <label style={{ cursor: 'pointer' }}>{children}</label> : children
}
const MessageItem: FC<Props> = ({ const MessageItem: FC<Props> = ({
message, message,
topic, topic,
@ -49,6 +60,7 @@ const MessageItem: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId) const { assistant, setModel } = useAssistant(message.assistantId)
const { isMultiSelectMode } = useChatContext(topic)
const model = useModel(getMessageModelId(message), message.model?.provider) || message.model const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
const { messageFont, fontSize, messageStyle } = useSettings() const { messageFont, fontSize, messageStyle } = useSettings()
const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic) const { editMessageBlocks, resendUserMessageWithEdit, editMessage } = useMessageOperations(topic)
@ -122,7 +134,15 @@ const MessageItem: FC<Props> = ({
if (message.type === 'clear') { if (message.type === 'clear') {
return ( return (
<NewContextMessage className="clear-context-divider" onClick={() => EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)}> <NewContextMessage
isMultiSelectMode={isMultiSelectMode}
className="clear-context-divider"
onClick={() => {
if (isMultiSelectMode) {
return
}
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
}}>
<Divider dashed style={{ padding: '0 20px' }} plain> <Divider dashed style={{ padding: '0 20px' }} plain>
{t('chat.message.new.context')} {t('chat.message.new.context')}
</Divider> </Divider>
@ -131,56 +151,64 @@ const MessageItem: FC<Props> = ({
} }
return ( return (
<MessageContainer <WrapperContainer isMultiSelectMode={isMultiSelectMode}>
key={message.id} <MessageContainer
className={classNames({ key={message.id}
message: true, className={classNames({
'message-assistant': isAssistantMessage, message: true,
'message-user': !isAssistantMessage 'message-assistant': isAssistantMessage,
})} 'message-user': !isAssistantMessage
ref={messageContainerRef}> })}
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} topic={topic} /> ref={messageContainerRef}>
{isEditing && ( <MessageHeader
<MessageEditor
message={message} message={message}
topicId={topic.id} assistant={assistant}
onSave={handleEditSave} model={model}
onResend={handleEditResend} key={getModelUniqId(model)}
onCancel={handleEditCancel} topic={topic}
/> />
)} {isEditing && (
{!isEditing && ( <MessageEditor
<> message={message}
<MessageContentContainer topicId={topic.id}
className="message-content-container" onSave={handleEditSave}
style={{ onResend={handleEditResend}
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)', onCancel={handleEditCancel}
fontSize, />
overflowY: 'visible' )}
}}> {!isEditing && (
<MessageErrorBoundary> <>
<MessageContent message={message} /> <MessageContentContainer
</MessageErrorBoundary> className="message-content-container"
</MessageContentContainer> style={{
{showMenubar && ( fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
<MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}> fontSize,
<MessageMenubar overflowY: 'visible'
message={message} }}>
assistant={assistant} <MessageErrorBoundary>
model={model} <MessageContent message={message} />
index={index} </MessageErrorBoundary>
topic={topic} </MessageContentContainer>
isLastMessage={isLastMessage} {showMenubar && (
isAssistantMessage={isAssistantMessage} <MessageFooter className="MessageFooter" $isLastMessage={isLastMessage} $messageStyle={messageStyle}>
isGrouped={isGrouped} <MessageMenubar
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>} message={message}
setModel={setModel} assistant={assistant}
/> model={model}
</MessageFooter> index={index}
)} topic={topic}
</> isLastMessage={isLastMessage}
)} isAssistantMessage={isAssistantMessage}
</MessageContainer> isGrouped={isGrouped}
messageContainerRef={messageContainerRef as React.RefObject<HTMLDivElement>}
setModel={setModel}
/>
</MessageFooter>
)}
</>
)}
</MessageContainer>
</WrapperContainer>
) )
} }
@ -232,9 +260,11 @@ const MessageFooter = styled.div<{ $isLastMessage: boolean; $messageStyle: 'plai
margin-top: 8px; margin-top: 8px;
` `
const NewContextMessage = styled.div` const NewContextMessage = styled.div<{ isMultiSelectMode: boolean }>`
cursor: pointer; cursor: pointer;
flex: 1; flex: 1;
${({ isMultiSelectMode }) => isMultiSelectMode && 'cursor: default;'}
` `
export default memo(MessageItem) export default memo(MessageItem)

View File

@ -17,9 +17,14 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 }) const [dragCurrent, setDragCurrent] = useState({ x: 0, y: 0 })
const [isMouseDown, setIsMouseDown] = useState(false)
const dragSelectedIds = useRef<Set<string>>(new Set()) const dragSelectedIds = useRef<Set<string>>(new Set())
// 拖拽阈值,只有移动距离超过这个值才开始框选
// 避免触控板点击触发拖拽
const DRAG_THRESHOLD = 5
useEffect(() => { useEffect(() => {
if (!isMultiSelectMode) return if (!isMultiSelectMode) return
@ -39,20 +44,30 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
e.preventDefault() e.preventDefault()
setIsDragging(true) setIsMouseDown(true)
const pos = updateDragPos(e) const pos = updateDragPos(e)
setDragStart(pos) setDragStart(pos)
setDragCurrent(pos) setDragCurrent(pos)
dragSelectedIds.current.clear() dragSelectedIds.current.clear()
document.body.classList.add('no-select')
} }
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isMouseDown) return
const pos = updateDragPos(e)
const deltaX = Math.abs(pos.x - dragStart.x)
const deltaY = Math.abs(pos.y - dragStart.y)
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (!isDragging && distance > DRAG_THRESHOLD) {
setIsDragging(true)
document.body.classList.add('no-select')
}
if (!isDragging) return if (!isDragging) return
e.preventDefault() e.preventDefault()
const pos = updateDragPos(e)
setDragCurrent(pos) setDragCurrent(pos)
// 计算当前框选矩形 // 计算当前框选矩形
@ -69,6 +84,9 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement | null const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement | null
const isAlreadySelected = checkbox?.checked || false const isAlreadySelected = checkbox?.checked || false
// 清除上下文这类消息也会被选中,所以需要跳过
if (!checkbox) return
// 如果已经被记录为拖动选中,跳过 // 如果已经被记录为拖动选中,跳过
if (dragSelectedIds.current.has(id)) return if (dragSelectedIds.current.has(id)) return
@ -94,9 +112,11 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
} }
const handleMouseUp = () => { const handleMouseUp = () => {
if (!isDragging) return setIsMouseDown(false)
setIsDragging(false) if (isDragging) {
document.body.classList.remove('no-select') setIsDragging(false)
document.body.classList.remove('no-select')
}
} }
const container = scrollContainerRef.current! const container = scrollContainerRef.current!
@ -110,7 +130,7 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
window.removeEventListener('mouseup', handleMouseUp) window.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('no-select') document.body.classList.remove('no-select')
} }
}, [isMultiSelectMode, isDragging, dragStart, scrollContainerRef, messageElements, handleSelectMessage]) }, [isMultiSelectMode, isDragging, isMouseDown, dragStart, scrollContainerRef, messageElements, handleSelectMessage])
if (!isDragging || !isMultiSelectMode) return null if (!isDragging || !isMultiSelectMode) return null