@@ -4,78 +4,6 @@ cursor: pointer; } -.messageContent { - &::selection, - & *::selection { - background-color: rgba(127, 125, 243, 0.35); - color: inherit; - } -} - -.selectionTooltipPopper { - &[data-popper-placement*='top'] .selectionTooltipArrow { - bottom: 0; - left: 0; - margin-bottom: -5px; - } - - &[data-popper-placement*='bottom'] .selectionTooltipArrow { - top: 0; - left: 0; - margin-top: -5px; - } -} - -.selectionTooltip { - position: relative; - display: flex; - align-items: center; - gap: 8px; - padding: 10px 13px; - background: #4b4b4b; - border-radius: 10px; - box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.04), 0px 4px 32px rgba(0, 0, 0, 0.16); -} - -.selectionTooltipArrow { - position: absolute; - width: 10px; - height: 10px; - pointer-events: none; - - &::before { - content: ''; - display: block; - width: 100%; - height: 100%; - background: #4b4b4b; - transform: rotate(45deg); - } -} - -.selectionTooltipButton { - background: transparent; - border: 0; - padding: 2px 6px; - cursor: pointer; - color: #7f7df3; - font-size: 14px; - font-weight: 600; - line-height: 1.3; - white-space: nowrap; - - &:hover { - color: #9b99ff; - } -} - -.selectionTooltipDivider { - width: 1px; - height: 14px; - background: rgba(127, 125, 243, 0.35); - flex-shrink: 0; -} - .userMessageImage { object-fit: contain; height: 100%; @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import { Box, ClickAwayListener, Menu, MenuItem, Popper, Typography } from '@mui/material' +import React, { useEffect } from 'react' +import { Box, Menu, MenuItem, Typography } from '@mui/material' import Stack from '@mui/material/Stack' import Image from 'next/image' @@ -11,61 +11,29 @@ import { parseThinkBlocks, stripThinkFromMain } from '../../../lib/parse-think-b import styles from './bot-message.module.scss' import { FullscreenIcon } from '../icons/fullscreen-icon' import { FullscreenMessageModal } from '../fullscreen-message-modal/fullscreen-message-modal' +import { SelectionReplyPopover, selectionReplyStyles, useSelectionReply } from '../selection-reply' import { ThinkBlock } from '../think-block/think-block' -type SelectionRect = { - top: number - left: number - width: number - height: number - bottom: number - right: number -} - -type SelectionTooltipState = { - text: string - rect: SelectionRect -} - -const SELECTION_TOOLTIP_Z_INDEX = 10050 - -function toSelectionRect(rect: DOMRect): SelectionRect { - return { - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - bottom: rect.bottom, - right: rect.right, - } -} - -function getChatViewportEl(from: HTMLElement | null): HTMLElement | null { - return from?.closest('[data-chat-viewport]') ?? null -} - -function isRectInChatViewport(rect: SelectionRect | DOMRect, viewport: HTMLElement | null): boolean { - if (!viewport) { - return false - } - const bounds = viewport.getBoundingClientRect() - return ( - rect.bottom > bounds.top && - rect.top < bounds.bottom && - rect.right > bounds.left && - rect.left < bounds.right - ) -} - export function BotMessage(props: any) { const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) const [markdownMessage, setMarkDownMessage] = React.useState('') const [fullscreenModalOpen, setFullscreenModalOpen] = React.useState(false) - const [selectionTooltip, setSelectionTooltip] = React.useState(null) - const contentRef = useRef(null) - const [selectionArrowEl, setSelectionArrowEl] = React.useState(null) - const ignoreClickAwayRef = useRef(false) + + const { + contentRef, + onContentMouseUp, + onContentTouchEnd, + selectionVirtualAnchor, + placement, + clearSelectionTooltip, + handleAskSelection, + handleCopySelection, + } = useSelectionReply({ + desktop: Boolean(props.desktop), + onQuote: props.setQuoteValue, + enabled: Boolean(props.setQuoteValue), + }) const copy = (text: string) => { navigator.clipboard.writeText(text) @@ -77,141 +45,6 @@ export function BotMessage(props: any) { setAnchorEl(null) } - const clearSelectionTooltip = useCallback(() => { - if (ignoreClickAwayRef.current) { - return - } - setSelectionTooltip(null) - }, []) - - const getSelectionInsideContent = useCallback(() => { - const selection = window.getSelection() - if (!selection || selection.isCollapsed || selection.rangeCount === 0) { - return null - } - - const text = selection.toString() - if (!text.trim()) { - return null - } - - const range = selection.getRangeAt(0) - const container = contentRef.current - if (!container?.contains(range.commonAncestorContainer)) { - return null - } - - const rect = range.getBoundingClientRect() - if (!rect.width && !rect.height) { - return null - } - - // Поповер не показываем, если выделение вне видимой области чата - const chatViewport = getChatViewportEl(container) - if (!isRectInChatViewport(rect, chatViewport)) { - return null - } - - return { text, rect: toSelectionRect(rect) } - }, []) - - const selectionVirtualAnchor = useMemo(() => { - if (!selectionTooltip) { - return null - } - const { rect } = selectionTooltip - return { - getBoundingClientRect: () => new DOMRect(rect.left, rect.top, rect.width, rect.height), - contextElement: contentRef.current ?? undefined, - } - }, [selectionTooltip]) - - const showSelectionTooltip = useCallback(() => { - const next = getSelectionInsideContent() - if (next) { - ignoreClickAwayRef.current = true - setSelectionTooltip(next) - window.setTimeout(() => { - ignoreClickAwayRef.current = false - }, 300) - return - } - setSelectionTooltip(null) - }, [getSelectionInsideContent]) - - const handleContentMouseUp = useCallback(() => { - showSelectionTooltip() - }, [showSelectionTooltip]) - - const handleContentTouchEnd = useCallback(() => { - // На тач-устройствах selection часто появляется после touchend - window.setTimeout(() => { - showSelectionTooltip() - }, 50) - }, [showSelectionTooltip]) - - const handleAskSelection = useCallback(() => { - if (!selectionTooltip?.text || !props.setQuoteValue) { - return - } - props.setQuoteValue(selectionTooltip.text) - window.getSelection()?.removeAllRanges() - setSelectionTooltip(null) - }, [props, selectionTooltip]) - - const handleCopySelection = useCallback(() => { - if (!selectionTooltip?.text) { - return - } - copy(selectionTooltip.text) - window.getSelection()?.removeAllRanges() - setSelectionTooltip(null) - }, [selectionTooltip]) - - useEffect(() => { - let timeoutId = 0 - - const handleSelectionChange = () => { - window.clearTimeout(timeoutId) - timeoutId = window.setTimeout(() => { - const next = getSelectionInsideContent() - if (next) { - ignoreClickAwayRef.current = true - setSelectionTooltip(next) - window.setTimeout(() => { - ignoreClickAwayRef.current = false - }, 300) - return - } - setSelectionTooltip(null) - }, 120) - } - - document.addEventListener('selectionchange', handleSelectionChange) - return () => { - window.clearTimeout(timeoutId) - document.removeEventListener('selectionchange', handleSelectionChange) - } - }, [getSelectionInsideContent]) - - useEffect(() => { - let scrollEndTimeout = 0 - - const handleScroll = () => { - setSelectionTooltip((current) => (current ? null : current)) - window.clearTimeout(scrollEndTimeout) - scrollEndTimeout = window.setTimeout(() => { - showSelectionTooltip() - }, 150) - } - - window.addEventListener('scroll', handleScroll, true) - return () => { - window.clearTimeout(scrollEndTimeout) - window.removeEventListener('scroll', handleScroll, true) - } - }, [showSelectionTooltip]) - useEffect(() => { if (props.message.file) { let splitFileName = props.message.file.split('/')[4].split('?')[0] @@ -318,14 +151,14 @@ export function BotMessage(props: any) { { if (props.message.file) { window.open(props.message.file, '_blank') } }} - className={`${styles.messageContent} smallScroll`} + className={`${selectionReplyStyles.messageContent} smallScroll`} sx={{ width: '100%', minWidth: 0, @@ -377,66 +210,13 @@ export function BotMessage(props: any) { )} {selectionVirtualAnchor ? ( - -
- -
e.preventDefault()} - > - - - - -
-
-
-
+ ) : null}
@@ -0,0 +1,3 @@ +export { SelectionReplyPopover } from './selection-reply-popover' +export { useSelectionReply } from './use-selection-reply' +export { default as selectionReplyStyles } from './selection-reply.module.scss' @@ -0,0 +1,66 @@ +import React, { useState } from 'react' +import { ClickAwayListener, Popper } from '@mui/material' + +import styles from './selection-reply.module.scss' +import type { SelectionVirtualAnchor } from './use-selection-reply' + +const SELECTION_TOOLTIP_Z_INDEX = 10050 + +type Props = { + anchor: SelectionVirtualAnchor + placement: 'top' | 'bottom' + onClose: () => void + onReply: () => void + onCopy: () => void +} + +export function SelectionReplyPopover({ anchor, placement, onClose, onReply, onCopy }: Props) { + const [arrowEl, setArrowEl] = useState(null) + + return ( + +
+ +
e.preventDefault()}> + + + + +
+
+
+
+ ) +} @@ -0,0 +1,79 @@ +.messageContent { + &::selection, + & *::selection { + background-color: rgba(127, 125, 243, 0.35); + color: inherit; + } +} + +.messageContentUser { + &::selection, + & *::selection { + background-color: rgba(40, 38, 120, 0.55); + color: inherit; + } +} + +.selectionTooltipPopper { + &[data-popper-placement*='top'] .selectionTooltipArrow { + bottom: 0; + left: 0; + margin-bottom: -5px; + } + + &[data-popper-placement*='bottom'] .selectionTooltipArrow { + top: 0; + left: 0; + margin-top: -5px; + } +} + +.selectionTooltip { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 13px; + background: #4b4b4b; + border-radius: 10px; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.04), 0px 4px 32px rgba(0, 0, 0, 0.16); +} + +.selectionTooltipArrow { + position: absolute; + width: 10px; + height: 10px; + pointer-events: none; + + &::before { + content: ''; + display: block; + width: 100%; + height: 100%; + background: #4b4b4b; + transform: rotate(45deg); + } +} + +.selectionTooltipButton { + background: transparent; + border: 0; + padding: 2px 6px; + cursor: pointer; + color: #7f7df3; + font-size: 14px; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + + &:hover { + color: #9b99ff; + } +} + +.selectionTooltipDivider { + width: 1px; + height: 14px; + background: rgba(127, 125, 243, 0.35); + flex-shrink: 0; +} @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +type SelectionRect = { + top: number + left: number + width: number + height: number + bottom: number + right: number +} + +type SelectionTooltipState = { + text: string + rect: SelectionRect +} + +export type SelectionVirtualAnchor = { + getBoundingClientRect: () => DOMRect + contextElement?: Element +} + +function toSelectionRect(rect: DOMRect): SelectionRect { + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + bottom: rect.bottom, + right: rect.right, + } +} + +function getChatViewportEl(from: HTMLElement | null): HTMLElement | null { + return from?.closest('[data-chat-viewport]') ?? null +} + +function isRectInChatViewport(rect: SelectionRect | DOMRect, viewport: HTMLElement | null): boolean { + if (!viewport) { + return false + } + const bounds = viewport.getBoundingClientRect() + return ( + rect.bottom > bounds.top && + rect.top < bounds.bottom && + rect.right > bounds.left && + rect.left < bounds.right + ) +} + +type Options = { + desktop: boolean + onQuote?: (value: string | null) => void + enabled?: boolean +} + +export function useSelectionReply({ desktop, onQuote, enabled = true }: Options) { + const [selectionTooltip, setSelectionTooltip] = useState(null) + const contentRef = useRef(null) + const ignoreClickAwayRef = useRef(false) + + const clearSelectionTooltip = useCallback(() => { + if (ignoreClickAwayRef.current) { + return + } + setSelectionTooltip(null) + }, []) + + const getSelectionInsideContent = useCallback(() => { + if (!enabled) { + return null + } + + const selection = window.getSelection() + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null + } + + const text = selection.toString() + if (!text.trim()) { + return null + } + + const range = selection.getRangeAt(0) + const container = contentRef.current + if (!container?.contains(range.commonAncestorContainer)) { + return null + } + + const rect = range.getBoundingClientRect() + if (!rect.width && !rect.height) { + return null + } + + const chatViewport = getChatViewportEl(container) + if (!isRectInChatViewport(rect, chatViewport)) { + return null + } + + return { text, rect: toSelectionRect(rect) } + }, [enabled]) + + const selectionVirtualAnchor = useMemo(() => { + if (!selectionTooltip) { + return null + } + const { rect } = selectionTooltip + return { + getBoundingClientRect: () => new DOMRect(rect.left, rect.top, rect.width, rect.height), + contextElement: contentRef.current ?? undefined, + } + }, [selectionTooltip]) + + const showSelectionTooltip = useCallback(() => { + const next = getSelectionInsideContent() + if (next) { + ignoreClickAwayRef.current = true + setSelectionTooltip(next) + window.setTimeout(() => { + ignoreClickAwayRef.current = false + }, 300) + return + } + setSelectionTooltip(null) + }, [getSelectionInsideContent]) + + const handleContentMouseUp = useCallback(() => { + showSelectionTooltip() + }, [showSelectionTooltip]) + + const handleContentTouchEnd = useCallback(() => { + window.setTimeout(() => { + showSelectionTooltip() + }, 50) + }, [showSelectionTooltip]) + + const handleAskSelection = useCallback(() => { + if (!selectionTooltip?.text || !onQuote) { + return + } + onQuote(selectionTooltip.text) + window.getSelection()?.removeAllRanges() + setSelectionTooltip(null) + }, [onQuote, selectionTooltip]) + + const handleCopySelection = useCallback(() => { + if (!selectionTooltip?.text) { + return + } + navigator.clipboard.writeText(selectionTooltip.text) + window.getSelection()?.removeAllRanges() + setSelectionTooltip(null) + }, [selectionTooltip]) + + useEffect(() => { + if (!enabled) { + return + } + + let timeoutId = 0 + + const handleSelectionChange = () => { + window.clearTimeout(timeoutId) + timeoutId = window.setTimeout(() => { + const next = getSelectionInsideContent() + if (next) { + ignoreClickAwayRef.current = true + setSelectionTooltip(next) + window.setTimeout(() => { + ignoreClickAwayRef.current = false + }, 300) + return + } + setSelectionTooltip(null) + }, 120) + } + + document.addEventListener('selectionchange', handleSelectionChange) + return () => { + window.clearTimeout(timeoutId) + document.removeEventListener('selectionchange', handleSelectionChange) + } + }, [enabled, getSelectionInsideContent]) + + useEffect(() => { + if (!enabled) { + return + } + + let scrollEndTimeout = 0 + + const handleScroll = () => { + setSelectionTooltip((current) => (current ? null : current)) + window.clearTimeout(scrollEndTimeout) + scrollEndTimeout = window.setTimeout(() => { + showSelectionTooltip() + }, 150) + } + + window.addEventListener('scroll', handleScroll, true) + return () => { + window.clearTimeout(scrollEndTimeout) + window.removeEventListener('scroll', handleScroll, true) + } + }, [enabled, showSelectionTooltip]) + + return { + contentRef, + onContentMouseUp: handleContentMouseUp, + onContentTouchEnd: handleContentTouchEnd, + selectionVirtualAnchor, + placement: (desktop ? 'top' : 'bottom') as 'top' | 'bottom', + clearSelectionTooltip, + handleAskSelection, + handleCopySelection, + } +} @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useMemo, useState, type Ref } from 'react' import { Box, Menu, MenuItem, Skeleton, Typography } from '@mui/material' import Stack from '@mui/material/Stack' import Image from 'next/image' @@ -12,10 +12,45 @@ import { Markdown } from '../../../lib/markdown/markdown' import { FullscreenIcon } from '../icons/fullscreen-icon' import { FullscreenMessageModal } from '../fullscreen-message-modal/fullscreen-message-modal' +import { SelectionReplyPopover, selectionReplyStyles, useSelectionReply } from '../selection-reply' import { UserMessageContent } from './user-message-content' import styles from '../bot-message/bot-message.module.scss' +type UserTextBubbleProps = { + content: string + contentRef: Ref + onMouseUp: () => void + onTouchEnd: () => void +} + +function UserTextBubble({ content, contentRef, onMouseUp, onTouchEnd }: UserTextBubbleProps) { + return ( + 30 ? 'pre-wrap' : 'pre', + }} + > + + + ) +} + interface IMessagesList { device: 'mobile' | 'desktop' modelType: string @@ -38,6 +73,20 @@ export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setM const [loaded, setLoaded] = useState(false) const [fullscreenModalOpen, setFullscreenModalOpen] = useState(false) + const { + contentRef, + onContentMouseUp, + onContentTouchEnd, + selectionVirtualAnchor, + placement, + clearSelectionTooltip, + handleAskSelection, + handleCopySelection, + } = useSelectionReply({ + desktop, + onQuote: props.setQuoteValue, + enabled: Boolean(props.setQuoteValue) && !props.message.from_model, + }) const copy = (text: string) => { navigator.clipboard.writeText(text) @@ -341,25 +390,12 @@ export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setM )} {props.message.content && props.message.content.length > 0 && ( - 30 ? 'pre-wrap' : 'pre', - }} - > - - + )}
@@ -597,25 +633,12 @@ export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setM )} {props.message.content && props.message.content.length > 0 && ( - 30 ? 'pre-wrap' : 'pre', - }} - > - - + )} @@ -801,29 +824,25 @@ export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setM )} {props.message.content && props.message.content.length > 0 && ( - 30 ? 'pre-wrap' : 'pre', - }} - > - - + )} )} + {selectionVirtualAnchor ? ( + + ) : null} ) : (