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

View File

@ -17,9 +17,14 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = 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 DRAG_THRESHOLD = 5
useEffect(() => {
if (!isMultiSelectMode) return
@ -39,20 +44,30 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
e.preventDefault()
setIsDragging(true)
setIsMouseDown(true)
const pos = updateDragPos(e)
setDragStart(pos)
setDragCurrent(pos)
dragSelectedIds.current.clear()
document.body.classList.add('no-select')
}
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
e.preventDefault()
const pos = updateDragPos(e)
setDragCurrent(pos)
// 计算当前框选矩形
@ -69,6 +84,9 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
const checkbox = el.querySelector('input[type="checkbox"]') as HTMLInputElement | null
const isAlreadySelected = checkbox?.checked || false
// 清除上下文这类消息也会被选中,所以需要跳过
if (!checkbox) return
// 如果已经被记录为拖动选中,跳过
if (dragSelectedIds.current.has(id)) return
@ -94,9 +112,11 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
}
const handleMouseUp = () => {
if (!isDragging) return
setIsDragging(false)
document.body.classList.remove('no-select')
setIsMouseDown(false)
if (isDragging) {
setIsDragging(false)
document.body.classList.remove('no-select')
}
}
const container = scrollContainerRef.current!
@ -110,7 +130,7 @@ const SelectionBox: React.FC<SelectionBoxProps> = ({
window.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('no-select')
}
}, [isMultiSelectMode, isDragging, dragStart, scrollContainerRef, messageElements, handleSelectMessage])
}, [isMultiSelectMode, isDragging, isMouseDown, dragStart, scrollContainerRef, messageElements, handleSelectMessage])
if (!isDragging || !isMultiSelectMode) return null