154 lines · 5.1 KB · TypeScript
RawDownload
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,  );}