import { ASSISTANT_CITATION_MAX_TEXT_LENGTH, MessageId, type AssistantCitation, type ScopedThreadRef, } from "@t3tools/contracts"; import { QuoteIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { captureAssistantTextSelection, type AssistantCitationSourceAnchor, } from "~/lib/assistantTextSelection"; import { observeSelectionActions, resolveSelectionActionPosition, type SelectionActionPoint, } from "~/lib/selectionActions"; import { Button } from "../ui/button"; export function AssistantSelectionToolbar({ viewport, threadRef, onCite, }: { viewport: HTMLElement | null; threadRef: ScopedThreadRef; onCite: (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => boolean; }) { const [selection, setSelection] = useState<{ citation: AssistantCitation; position: SelectionActionPoint; sourceAnchor: AssistantCitationSourceAnchor; } | null>(null); const toolbarRef = useRef(null); const actionsRef = useRef | null>(null); useLayoutEffect(() => { const toolbar = toolbarRef.current; if (!toolbar || !selection) return; const rect = toolbar.getBoundingClientRect(); toolbar.style.left = `${Math.max(8, Math.min(selection.position.x, window.innerWidth - rect.width - 8))}px`; toolbar.style.top = `${Math.max(8, Math.min(selection.position.y, window.innerHeight - rect.height - 8))}px`; }, [selection]); useEffect(() => { if (!viewport) return; const clear = () => setSelection(null); const update = (pointer: SelectionActionPoint | null) => { const nativeSelection = window.getSelection(); const captured = captureAssistantTextSelection(viewport, nativeSelection); const messageId = captured?.source.dataset.assistantCitationSource; if (!captured || !messageId) { clear(); return; } const rect = captured.range.getBoundingClientRect(); const viewportRect = viewport.getBoundingClientRect(); if (rect.bottom < viewportRect.top || rect.top > viewportRect.bottom || rect.width === 0) { clear(); return; } const rects = captured.range.getClientRects(); setSelection({ sourceAnchor: { source: captured.source, range: captured.range, viewport }, citation: { version: 1, ...threadRef, messageId: MessageId.make(messageId), ...captured.selector, }, position: resolveSelectionActionPosition({ bounds: viewportRect, selectionRect: rects.item(rects.length - 1) ?? rect, pointer, viewport: { width: window.innerWidth, height: window.innerHeight }, }), }); }; const actions = observeSelectionActions({ element: viewport, getActionElement: () => toolbarRef.current, onSelection: update, onDismiss: clear, }); actionsRef.current = actions; const focusActions = (event: KeyboardEvent) => { const toolbar = toolbarRef.current; if ( event.key !== "Tab" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || event.isComposing || event.defaultPrevented || !toolbar || toolbar.contains(event.target as Node) ) { return; } if (toolbar.disabled) return; event.preventDefault(); event.stopPropagation(); toolbar.focus({ preventScroll: true }); }; document.addEventListener("keydown", focusActions, true); document.addEventListener("selectionchange", actions.selectionChanged); return () => { document.removeEventListener("keydown", focusActions, true); document.removeEventListener("selectionchange", actions.selectionChanged); actions.dispose(); actionsRef.current = null; }; }, [threadRef, viewport]); if (!selection) return null; const tooLong = selection.citation.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH; const dismiss = () => { actionsRef.current?.cancel(); setSelection(null); }; const cite = () => { if (tooLong || !onCite(selection.citation, selection.sourceAnchor)) return false; window.getSelection()?.removeAllRanges(); dismiss(); return true; }; return createPortal( , document.body, ); }