import React from 'react'; import { createPortal } from 'react-dom'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import { useInputStore } from '@/sync/input-store'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { cn } from '@/lib/utils'; import { copyTextToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH, getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig'; import { summarizeSelectionForNotes } from '@/lib/smallModel'; import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; interface TextSelectionMenuProps { containerRef: React.RefObject; } interface MenuPosition { x: number; y: number; show: boolean; } interface SelectionPayload { plainText: string; markdownText: string; rect: DOMRect; } const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => { const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '').slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH); if (!trimmedInsight) { return existingNotes; } const trimmedNotes = existingNotes.trimEnd(); return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight; }; const DESKTOP_MENU_SIDE_MARGIN_PX = 8; const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; export const TextSelectionMenu: React.FC = ({ containerRef }) => { const { t } = useI18n(); const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); const isDraggingRef = React.useRef(false); const [isOpening, setIsOpening] = React.useState(false); const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); const menuRef = React.useRef(null); const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX); const pendingSelectionRef = React.useRef(null); const openRafRef = React.useRef(null); const mouseUpTimeoutRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); const createSession = useSessionUIStore((state) => state.createSession); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); const projects = useProjectsStore((state) => state.projects); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const effectiveDirectory = useEffectiveDirectory(); const sessions = useSessions(); React.useEffect(() => { isMenuVisibleRef.current = position.show; }, [position.show]); React.useEffect(() => { return () => { if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); openRafRef.current = null; } if (mouseUpTimeoutRef.current !== null) { window.clearTimeout(mouseUpTimeoutRef.current); mouseUpTimeoutRef.current = null; } }; }, []); const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; if (!isMenuVisibleRef.current) { return; } if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); openRafRef.current = null; } setIsOpening(false); setPosition((prev) => ({ ...prev, show: false })); setSelectedText(''); setSelectedTextMarkdown(''); isMenuVisibleRef.current = false; }, []); const getDesktopClampedX = React.useCallback((anchorX: number) => { if (typeof window === 'undefined') { return anchorX; } const viewportWidth = window.innerWidth; const menuWidth = menuWidthRef.current; const halfWidth = menuWidth / 2; const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth; const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth; if (minX > maxX) { return viewportWidth / 2; } return Math.min(Math.max(anchorX, minX), maxX); }, []); const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; const { plainText, markdownText, rect } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; // Position menu above the selection const menuX = isMobile ? rect.left + rect.width / 2 : getDesktopClampedX(rect.left + rect.width / 2); const menuY = rect.top - 10; setSelectedText(plainText); setSelectedTextMarkdown(markdownText); setPosition({ x: menuX, y: menuY, show: true, }); isMenuVisibleRef.current = true; if (shouldAnimateIn) { setIsOpening(true); if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); } openRafRef.current = window.requestAnimationFrame(() => { setIsOpening(false); openRafRef.current = null; }); } }, [getDesktopClampedX, isMobile, position.show]); React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { return; } const measuredWidth = menuRef.current.offsetWidth; if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) { return; } menuWidthRef.current = measuredWidth; setPosition((prev) => ({ ...prev, x: getDesktopClampedX(prev.x), })); }, [getDesktopClampedX, isMobile, position.show]); React.useEffect(() => { if (!position.show || isMobile) { return; } const handleViewportResize = () => { setPosition((prev) => ({ ...prev, x: getDesktopClampedX(prev.x), })); }; window.addEventListener('resize', handleViewportResize); return () => { window.removeEventListener('resize', handleViewportResize); }; }, [getDesktopClampedX, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { const selection = window.getSelection(); const container = containerRef.current; if (!selection || !container) { if (!isDraggingRef.current) { hideMenu(); } return; } const text = trimSelectionValue(selection.toString()); // Only show if we have text and the selection is within our container if (!text) { if (!isDraggingRef.current) { hideMenu(); } return; } // Check if selection is within the container const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) { if (!isDraggingRef.current) { hideMenu(); } return; } // Get selection coordinates const rect = range.getBoundingClientRect(); // Store the selection but don't show menu yet if dragging pendingSelectionRef.current = { plainText: text, markdownText: rangeToMarkdown(range, text), rect, }; // Only show menu if we're not currently dragging if (!isDraggingRef.current) { showMenu(); } }, [containerRef, hideMenu, showMenu]); React.useEffect(() => { const container = containerRef.current; if (!container) return; // Track when dragging starts const handleMouseDown = () => { isDraggingRef.current = true; hideMenu(); }; // Track when dragging stops const handleMouseUp = () => { isDraggingRef.current = false; // Check if we have a pending selection to show if (pendingSelectionRef.current) { if (mouseUpTimeoutRef.current !== null) { window.clearTimeout(mouseUpTimeoutRef.current); } // Small delay to ensure selection is finalized mouseUpTimeoutRef.current = window.setTimeout(() => { mouseUpTimeoutRef.current = null; 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 () => { if (mouseUpTimeoutRef.current !== null) { window.clearTimeout(mouseUpTimeoutRef.current); mouseUpTimeoutRef.current = null; } 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 (!selectedTextMarkdown) return; const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown); setPendingInputText(markdownBlock, 'append'); hideMenu(); // Clear selection window.getSelection()?.removeAllRanges(); }, [selectedTextMarkdown, 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; const result = await copyTextToClipboard(selectedText); if (!result.ok) { console.error('Failed to copy:', result.error); } hideMenu(); window.getSelection()?.removeAllRanges(); }, [selectedText, hideMenu]); const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; } return sessions.find((session) => session.id === currentSessionId) ?? null; }, [currentSessionId, sessions]); const currentProjectRef = React.useMemo(() => { const directory = effectiveDirectory ?? (typeof currentSession?.directory === 'string' ? currentSession.directory : ''); const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); return resolved ? { id: resolved.id, path: resolved.path } : null; }, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]); const handleAddToNotes = React.useCallback(async () => { if (!selectedText || !currentProjectRef) { if (!currentProjectRef) { toast.error(t('chat.textSelection.toast.noProject')); } return; } try { setIsAddingToNotes(true); // Long selections are distilled into a compact note by the small model; // short ones (and any generation failure) go in verbatim. const noteText = await summarizeSelectionForNotes(selectedTextMarkdown || selectedText, currentSessionId); const projectData = await getProjectNotesAndTodos(currentProjectRef); const nextNotes = appendDistilledInsightToNotes(projectData.notes, noteText); const saved = await saveProjectNotesAndTodos(currentProjectRef, { notes: nextNotes, todos: projectData.todos, }); if (!saved) { toast.error(t('chat.textSelection.toast.addToNotesFailed')); return; } window.dispatchEvent(new CustomEvent('openchamber:project-notes-updated', { detail: { projectId: currentProjectRef.id }, })); toast.success(t('chat.textSelection.toast.addToNotesSuccess')); hideMenu(); window.getSelection()?.removeAllRanges(); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('chat.textSelection.toast.addToNotesFailed'), description ? { description } : undefined); } finally { setIsAddingToNotes(false); } }, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]); if (!position.show) return null; // Mobile: Show as a bar at the bottom of the screen, above the keyboard if (isMobile) { return createPortal(
{!isVSCodeRuntime() ? ( ) : null}
, document.body ); } // Desktop: Show as a popup above the selection return createPortal(
{!isVSCodeRuntime() ? ( <>
) : null}
, document.body ); };