made filter popover appear at @ place

This commit is contained in:
Lucas Oliveira 2025-10-02 15:00:42 -03:00
parent 3fae4c0fa3
commit 9fcad31aa9
2 changed files with 133 additions and 41 deletions

View file

@ -1,31 +1,33 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Popover = PopoverPrimitive.Root
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };

View file

@ -20,11 +20,7 @@ import { MarkdownRenderer } from "@/components/markdown-renderer";
import { ProtectedRoute } from "@/components/protected-route";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { useAuth } from "@/contexts/auth-context";
import { type EndpointType, useChat } from "@/contexts/chat-context";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
@ -136,6 +132,7 @@ function ChatPage() {
const [dropdownDismissed, setDropdownDismissed] = useState(false);
const [isUserInteracting, setIsUserInteracting] = useState(false);
const [isForkingInProgress, setIsForkingInProgress] = useState(false);
const [anchorPosition, setAnchorPosition] = useState<{ x: number; y: number } | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -150,6 +147,59 @@ function ChatPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const getCursorPosition = (textarea: HTMLTextAreaElement) => {
// Create a hidden div with the same styles as the textarea
const div = document.createElement('div');
const computedStyle = getComputedStyle(textarea);
// Copy all computed styles to the hidden div
for (const style of computedStyle) {
(div.style as any)[style] = computedStyle.getPropertyValue(style);
}
// Set the div to be hidden but not un-rendered
div.style.position = 'absolute';
div.style.visibility = 'hidden';
div.style.whiteSpace = 'pre-wrap';
div.style.wordWrap = 'break-word';
div.style.overflow = 'hidden';
div.style.height = 'auto';
div.style.width = `${textarea.getBoundingClientRect().width}px`;
// Get the text up to the cursor position
const cursorPos = textarea.selectionStart || 0;
const textBeforeCursor = textarea.value.substring(0, cursorPos);
// Add the text before cursor
div.textContent = textBeforeCursor;
// Create a span to mark the end position
const span = document.createElement('span');
span.textContent = '|'; // Cursor marker
div.appendChild(span);
// Add the text after cursor to handle word wrapping
const textAfterCursor = textarea.value.substring(cursorPos);
div.appendChild(document.createTextNode(textAfterCursor));
// Add the div to the document temporarily
document.body.appendChild(div);
// Get positions
const inputRect = textarea.getBoundingClientRect();
const divRect = div.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
// Calculate the cursor position relative to the input
const x = inputRect.left + (spanRect.left - divRect.left);
const y = inputRect.top + (spanRect.top - divRect.top);
// Clean up
document.body.removeChild(div);
return { x, y };
};
const handleEndpointChange = (newEndpoint: EndpointType) => {
setEndpoint(newEndpoint);
// Clear the conversation when switching endpoints to avoid response ID conflicts
@ -692,7 +742,6 @@ function ChatPage() {
};
}, [endpoint, setPreviousResponseIds]);
// Handle click outside to close dropdown
const { data: nudges = [], cancel: cancelNudges } = useGetNudgesQuery(
previousResponseIds[endpoint],
@ -2065,6 +2114,12 @@ function ChatPage() {
setFilterSearchTerm(searchTerm);
setSelectedFilterIndex(0);
// Only set anchor position when @ is first detected (search term is empty)
if (searchTerm === "" && !anchorPosition) {
const cursorPos = getCursorPosition(e.target);
setAnchorPosition(cursorPos);
}
if (!isFilterDropdownOpen) {
loadAvailableFilters();
setIsFilterDropdownOpen(true);
@ -2074,6 +2129,7 @@ function ChatPage() {
console.log("Closing dropdown - no @ found");
setIsFilterDropdownOpen(false);
setFilterSearchTerm("");
setAnchorPosition(null);
}
// Reset dismissed flag when user moves to a different word
@ -2205,30 +2261,64 @@ function ChatPage() {
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
onClick={() => {
if (!isFilterDropdownOpen) {
loadAvailableFilters();
setIsFilterDropdownOpen(true);
// Get cursor position when manually opening
if (inputRef.current) {
const cursorPos = getCursorPosition(inputRef.current);
setAnchorPosition(cursorPos);
}
} else {
setIsFilterDropdownOpen(false);
setAnchorPosition(null);
}
}}
>
<AtSign className="h-4 w-4" />
</Button>
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
if (open) {
loadAvailableFilters();
if (!open) {
setAnchorPosition(null);
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: 'fixed',
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: 'none',
}}
>
<AtSign className="h-4 w-4" />
</Button>
</PopoverTrigger>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={8}
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
inputRef.current?.focus();
}}
>
<div className="space-y-1">
{filterSearchTerm && (