import React from 'react'; import { createPortal } from 'react-dom'; import { useSessionStore } from '@/stores/useSessionStore'; import { useUIStore } from '@/stores/useUIStore'; import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react'; import { cn } from '@/lib/utils'; interface TextSelectionMenuProps { containerRef: React.RefObject; } interface MenuPosition { x: number; y: number; show: boolean; } export const TextSelectionMenu: React.FC = ({ containerRef }) => { const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); const [isClosing, setIsClosing] = React.useState(false); const menuRef = React.useRef(null); const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null); const hideTimeoutRef = React.useRef(null); const createSession = useSessionStore((state) => state.createSession); const setPendingInputText = useSessionStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); React.useEffect(() => { return () => { if (hideTimeoutRef.current !== null) { window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } }; }, []); const hideMenu = React.useCallback(() => { if (hideTimeoutRef.current !== null) { window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } setIsClosing(true); hideTimeoutRef.current = window.setTimeout(() => { setPosition((prev) => ({ ...prev, show: false })); setSelectedText(''); pendingSelectionRef.current = null; setIsClosing(false); hideTimeoutRef.current = null; }, 140); }, []); const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; if (hideTimeoutRef.current !== null) { window.clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } setIsClosing(false); const { text, rect } = pendingSelectionRef.current; // Position menu above the selection const menuX = rect.left + rect.width / 2; const menuY = rect.top - 10; setSelectedText(text); setPosition({ x: menuX, y: menuY, show: true, }); }, []); const handleSelectionChange = React.useCallback(() => { const selection = window.getSelection(); const container = containerRef.current; if (!selection || !container) { if (!isDragging) { hideMenu(); } return; } const text = selection.toString().trim(); // Only show if we have text and the selection is within our container if (!text) { if (!isDragging) { hideMenu(); } return; } // Check if selection is within the container const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { if (!isDragging) { hideMenu(); } return; } // Get selection coordinates const rect = range.getBoundingClientRect(); // Store the selection but don't show menu yet if dragging pendingSelectionRef.current = { text, rect }; // Only show menu if we're not currently dragging if (!isDragging) { showMenu(); } }, [containerRef, hideMenu, showMenu, isDragging]); React.useEffect(() => { const container = containerRef.current; if (!container) return; // Track when dragging starts const handleMouseDown = () => { setIsDragging(true); hideMenu(); }; // Track when dragging stops const handleMouseUp = () => { setIsDragging(false); // Check if we have a pending selection to show if (pendingSelectionRef.current) { // Small delay to ensure selection is finalized setTimeout(() => { const selection = window.getSelection(); if (selection && selection.toString().trim()) { showMenu(); } else { hideMenu(); } }, 10); } }; // Listen for selection changes during drag document.addEventListener('selectionchange', handleSelectionChange); container.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); // Hide menu when clicking outside const handleClickOutside = (e: MouseEvent) => { if ( menuRef.current && !menuRef.current.contains(e.target as Node) && !window.getSelection()?.toString().trim() ) { hideMenu(); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('selectionchange', handleSelectionChange); container.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousedown', handleClickOutside); }; }, [containerRef, handleSelectionChange, hideMenu, showMenu]); const handleAddToChat = React.useCallback(() => { if (!selectedText) return; setPendingInputText(selectedText, 'append'); hideMenu(); // Clear selection window.getSelection()?.removeAllRanges(); }, [selectedText, setPendingInputText, hideMenu]); const handleCreateNewSession = React.useCallback(async () => { if (!selectedText) return; const session = await createSession(undefined, null, null); if (session) { setPendingInputText(selectedText, 'replace'); } hideMenu(); window.getSelection()?.removeAllRanges(); }, [selectedText, createSession, setPendingInputText, hideMenu]); const handleCopy = React.useCallback(async () => { if (!selectedText) return; try { await navigator.clipboard.writeText(selectedText); } catch (err) { console.error('Failed to copy:', err); } hideMenu(); window.getSelection()?.removeAllRanges(); }, [selectedText, hideMenu]); if (!position.show) return null; // Mobile: Show as a bar at the bottom of the screen, above the keyboard if (isMobile) { return createPortal(
, document.body ); } // Desktop: Show as a popup above the selection return createPortal(
, document.body ); }; export default TextSelectionMenu;