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<HTMLButtonElement>(null);
const actionsRef = useRef<ReturnType<typeof observeSelectionActions> | 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(
<Button
ref={toolbarRef}
type="button"
size="xs"
variant="glass"
disabled={tooLong}
aria-label={tooLong ? "Selection is too long to cite" : "Cite selection in composer"}
className="fixed z-50 max-w-[calc(100vw-1rem)] rounded-full px-2.5"
style={{ left: selection.position.x, top: selection.position.y }}
onPointerDown={(event) => event.preventDefault()}
onClick={cite}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Escape" && !event.nativeEvent.isComposing) {
event.preventDefault();
dismiss();
}
}}
>
<QuoteIcon aria-hidden="true" className="size-3.5" />
{tooLong ? "Shorten selection" : "Cite"}
</Button>,
document.body,
);
}