1import {
2 ASSISTANT_CITATION_MAX_TEXT_LENGTH,
3 MessageId,
4 type AssistantCitation,
5 type ScopedThreadRef,
6} from "@t3tools/contracts";
7import { QuoteIcon } from "lucide-react";
8import { useEffect, useLayoutEffect, useRef, useState } from "react";
9import { createPortal } from "react-dom";
10import {
11 captureAssistantTextSelection,
12 type AssistantCitationSourceAnchor,
13} from "~/lib/assistantTextSelection";
14import {
15 observeSelectionActions,
16 resolveSelectionActionPosition,
17 type SelectionActionPoint,
18} from "~/lib/selectionActions";
19import { Button } from "../ui/button";
20
21export function AssistantSelectionToolbar({
22 viewport,
23 threadRef,
24 onCite,
25}: {
26 viewport: HTMLElement | null;
27 threadRef: ScopedThreadRef;
28 onCite: (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => boolean;
29}) {
30 const [selection, setSelection] = useState<{
31 citation: AssistantCitation;
32 position: SelectionActionPoint;
33 sourceAnchor: AssistantCitationSourceAnchor;
34 } | null>(null);
35 const toolbarRef = useRef<HTMLButtonElement>(null);
36 const actionsRef = useRef<ReturnType<typeof observeSelectionActions> | null>(null);
37
38 useLayoutEffect(() => {
39 const toolbar = toolbarRef.current;
40 if (!toolbar || !selection) return;
41 const rect = toolbar.getBoundingClientRect();
42 toolbar.style.left = `${Math.max(8, Math.min(selection.position.x, window.innerWidth - rect.width - 8))}px`;
43 toolbar.style.top = `${Math.max(8, Math.min(selection.position.y, window.innerHeight - rect.height - 8))}px`;
44 }, [selection]);
45
46 useEffect(() => {
47 if (!viewport) return;
48 const clear = () => setSelection(null);
49 const update = (pointer: SelectionActionPoint | null) => {
50 const nativeSelection = window.getSelection();
51 const captured = captureAssistantTextSelection(viewport, nativeSelection);
52 const messageId = captured?.source.dataset.assistantCitationSource;
53 if (!captured || !messageId) {
54 clear();
55 return;
56 }
57 const rect = captured.range.getBoundingClientRect();
58 const viewportRect = viewport.getBoundingClientRect();
59 if (rect.bottom < viewportRect.top || rect.top > viewportRect.bottom || rect.width === 0) {
60 clear();
61 return;
62 }
63 const rects = captured.range.getClientRects();
64 const pointerOnText =
65 pointer !== null &&
66 Array.from(rects).some(
67 (line) =>
68 pointer.x >= line.left - 8 &&
69 pointer.x <= line.right + 8 &&
70 pointer.y >= line.top - 8 &&
71 pointer.y <= line.bottom + 8,
72 );
73 setSelection({
74 sourceAnchor: { source: captured.source, range: captured.range, viewport },
75 citation: {
76 version: 1,
77 ...threadRef,
78 messageId: MessageId.make(messageId),
79 ...captured.selector,
80 },
81 position: resolveSelectionActionPosition({
82 bounds: viewportRect,
83 selectionRect: rects.item(rects.length - 1) ?? rect,
84 pointer: pointerOnText ? pointer : null,
85 viewport: { width: window.innerWidth, height: window.innerHeight },
86 }),
87 });
88 };
89 const actions = observeSelectionActions({
90 element: viewport,
91 getActionElement: () => toolbarRef.current,
92 onSelection: update,
93 onDismiss: clear,
94 });
95 actionsRef.current = actions;
96 const focusActions = (event: KeyboardEvent) => {
97 const toolbar = toolbarRef.current;
98 if (
99 event.key !== "Tab" ||
100 event.shiftKey ||
101 event.altKey ||
102 event.ctrlKey ||
103 event.metaKey ||
104 event.isComposing ||
105 event.defaultPrevented ||
106 !toolbar ||
107 toolbar.contains(event.target as Node)
108 ) {
109 return;
110 }
111 if (toolbar.disabled) return;
112 event.preventDefault();
113 event.stopPropagation();
114 toolbar.focus({ preventScroll: true });
115 };
116 document.addEventListener("keydown", focusActions, true);
117 document.addEventListener("selectionchange", actions.selectionChanged);
118 return () => {
119 document.removeEventListener("keydown", focusActions, true);
120 document.removeEventListener("selectionchange", actions.selectionChanged);
121 actions.dispose();
122 actionsRef.current = null;
123 };
124 }, [threadRef, viewport]);
125
126 if (!selection) return null;
127 const tooLong = selection.citation.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH;
128 const dismiss = () => {
129 actionsRef.current?.cancel();
130 setSelection(null);
131 };
132 const cite = () => {
133 if (tooLong || !onCite(selection.citation, selection.sourceAnchor)) return false;
134 window.getSelection()?.removeAllRanges();
135 dismiss();
136 return true;
137 };
138 return createPortal(
139 <Button
140 ref={toolbarRef}
141 type="button"
142 size="xs"
143 variant="glass"
144 disabled={tooLong}
145 aria-label={tooLong ? "Selection is too long to cite" : "Cite selection in composer"}
146 className="fixed z-50 max-w-[calc(100vw-1rem)]"
147 style={{ left: selection.position.x, top: selection.position.y }}
148 onPointerDown={(event) => event.preventDefault()}
149 onClick={cite}
150 onKeyDown={(event) => {
151 event.stopPropagation();
152 if (event.key === "Escape" && !event.nativeEvent.isComposing) {
153 event.preventDefault();
154 dismiss();
155 }
156 }}
157 >
158 <QuoteIcon aria-hidden="true" className="size-3.5" />
159 {tooLong ? "Shorten selection" : "Cite"}
160 </Button>,
161 document.body,
162 );
163}