import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { MessageCircleMore } from "lucide-react"; import { useTranslation } from "react-i18next"; const MAX_QUOTED_CONTEXT_CHARS = 4_000; interface SelectionActionState { text: string; left: number; top: number; above: boolean; } interface AssistantSelectionActionProps { containerRef: RefObject; onQuoteSelection?: (text: string) => void; } function selectableAncestor(node: Node | null, container: HTMLElement): HTMLElement | null { const element = node instanceof Element ? node : node?.parentElement; const selectable = element?.closest("[data-assistant-selectable='true']") ?? null; return selectable && container.contains(selectable) ? selectable : null; } function selectedRangeRect(range: Range): DOMRect | null { const rect = range.getBoundingClientRect(); if (rect.width > 0 || rect.height > 0) return rect; return range.getClientRects()[0] ?? null; } function normalizedSelectionText(selection: Selection): string { return selection .toString() .replace(/\u00a0/g, " ") .replace(/\r\n?/g, "\n") .trim() .slice(0, MAX_QUOTED_CONTEXT_CHARS); } export function AssistantSelectionAction({ containerRef, onQuoteSelection, }: AssistantSelectionActionProps) { const { t } = useTranslation(); const [action, setAction] = useState(null); const frameRef = useRef(null); const actionRef = useRef(null); useLayoutEffect(() => { const element = actionRef.current; if (!action || !element) return; const viewport = window.visualViewport; const viewportLeft = viewport?.offsetLeft ?? 0; const viewportTop = viewport?.offsetTop ?? 0; const viewportRight = viewportLeft + (viewport?.width ?? window.innerWidth); const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); const rect = element.getBoundingClientRect(); const padding = 12; const shiftX = rect.left < viewportLeft + padding ? viewportLeft + padding - rect.left : rect.right > viewportRight - padding ? viewportRight - padding - rect.right : 0; const shiftY = rect.top < viewportTop + padding ? viewportTop + padding - rect.top : rect.bottom > viewportBottom - padding ? viewportBottom - padding - rect.bottom : 0; element.style.translate = `${shiftX}px ${shiftY}px`; }, [action]); useEffect(() => { if (!onQuoteSelection) return; const updateFromSelection = () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); frameRef.current = requestAnimationFrame(() => { frameRef.current = null; const container = containerRef.current; const selection = window.getSelection(); if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) { setAction(null); return; } const range = selection.getRangeAt(0); const start = selectableAncestor(range.startContainer, container); const end = selectableAncestor(range.endContainer, container); const text = normalizedSelectionText(selection); const rect = selectedRangeRect(range); if (!start || start !== end || !text || !rect) { setAction(null); return; } const viewport = window.visualViewport; const viewportTop = viewport?.offsetTop ?? 0; const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); const above = rect.bottom + 52 > viewportBottom; setAction({ text, left: rect.left + rect.width / 2, top: above ? rect.top - 8 : rect.bottom + 8, above, }); }); }; const dismiss = () => setAction(null); const onPointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Element && target.closest("[data-selection-follow-up='true']")) return; dismiss(); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); }; document.addEventListener("selectionchange", updateFromSelection); document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("scroll", dismiss, true); document.addEventListener("keydown", onKeyDown); window.addEventListener("resize", dismiss); window.visualViewport?.addEventListener("resize", dismiss); window.visualViewport?.addEventListener("scroll", dismiss); return () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); document.removeEventListener("selectionchange", updateFromSelection); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("scroll", dismiss, true); document.removeEventListener("keydown", onKeyDown); window.removeEventListener("resize", dismiss); window.visualViewport?.removeEventListener("resize", dismiss); window.visualViewport?.removeEventListener("scroll", dismiss); }; }, [containerRef, onQuoteSelection]); if (!action || typeof document === "undefined") return null; return createPortal( , document.body, ); }