mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
refactor: implement CitationHoverPopover component to enhance inline citation functionality and improve user interaction
This commit is contained in:
parent
56239548c8
commit
ea087d1d23
3 changed files with 293 additions and 210 deletions
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
|
||||
type PopoverContentProps = ComponentProps<typeof PopoverContent>;
|
||||
|
||||
export type CitationHoverTriggerProps = Pick<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
"onBlur" | "onFocus" | "onPointerEnter" | "onPointerLeave"
|
||||
>;
|
||||
|
||||
interface CitationHoverPopoverProps {
|
||||
id: string;
|
||||
trigger: (props: CitationHoverTriggerProps) => ReactElement;
|
||||
children: ReactNode;
|
||||
contentClassName?: string;
|
||||
side?: PopoverContentProps["side"];
|
||||
align?: PopoverContentProps["align"];
|
||||
sideOffset?: PopoverContentProps["sideOffset"];
|
||||
onContentClick?: PopoverContentProps["onClick"];
|
||||
}
|
||||
|
||||
const OPEN_DELAY_MS = 80;
|
||||
const CLOSE_DELAY_MS = 120;
|
||||
|
||||
let activeCitationId: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return activeCitationId;
|
||||
}
|
||||
|
||||
function setActiveCitationId(id: string | null) {
|
||||
if (activeCitationId === id) return;
|
||||
activeCitationId = id;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function useCitationHoverState(id: string) {
|
||||
const activeId = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimerRef.current) {
|
||||
clearTimeout(openTimerRef.current);
|
||||
openTimerRef.current = null;
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const open = activeId === id;
|
||||
|
||||
const scheduleOpen = useCallback(() => {
|
||||
clearTimers();
|
||||
openTimerRef.current = setTimeout(() => {
|
||||
setActiveCitationId(id);
|
||||
openTimerRef.current = null;
|
||||
}, OPEN_DELAY_MS);
|
||||
}, [clearTimers, id]);
|
||||
|
||||
const scheduleClose = useCallback(() => {
|
||||
clearTimers();
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
if (activeCitationId === id) {
|
||||
setActiveCitationId(null);
|
||||
}
|
||||
closeTimerRef.current = null;
|
||||
}, CLOSE_DELAY_MS);
|
||||
}, [clearTimers, id]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
clearTimers();
|
||||
setActiveCitationId(nextOpen ? id : null);
|
||||
},
|
||||
[clearTimers, id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (activeCitationId === id) {
|
||||
setActiveCitationId(null);
|
||||
}
|
||||
};
|
||||
}, [clearTimers, id]);
|
||||
|
||||
return { open, scheduleOpen, scheduleClose, handleOpenChange };
|
||||
}
|
||||
|
||||
export function CitationHoverPopover({
|
||||
id,
|
||||
trigger,
|
||||
children,
|
||||
contentClassName,
|
||||
side = "top",
|
||||
align = "start",
|
||||
sideOffset = 6,
|
||||
onContentClick,
|
||||
}: CitationHoverPopoverProps) {
|
||||
const { open, scheduleOpen, scheduleClose, handleOpenChange } = useCitationHoverState(id);
|
||||
const hoverProps = {
|
||||
onPointerEnter: scheduleOpen,
|
||||
onPointerLeave: scheduleClose,
|
||||
onFocus: scheduleOpen,
|
||||
onBlur: scheduleClose,
|
||||
} satisfies CitationHoverTriggerProps;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>{trigger(hoverProps)}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={contentClassName}
|
||||
onPointerEnter={scheduleOpen}
|
||||
onPointerLeave={scheduleClose}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onClick={onContentClick}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { ExternalLink, Globe } from "lucide-react";
|
||||
import NextImage from "next/image";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openSafeNavigationHref, sanitizeHref } from "../shared/media";
|
||||
import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter";
|
||||
import { cn } from "./_adapter";
|
||||
import { CitationHoverPopover } from "./citation-hover-popover";
|
||||
import type { CitationVariant, SerializableCitation } from "./schema";
|
||||
import { TYPE_ICONS } from "./type-icons";
|
||||
|
||||
|
|
@ -32,29 +32,6 @@ function formatDate(isoString: string, locale: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function useHoverPopover(delay = 100) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(true), delay);
|
||||
}, [delay]);
|
||||
|
||||
const handleMouseLeave = React.useCallback(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setOpen(false), delay);
|
||||
}, [delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { open, setOpen, handleMouseEnter, handleMouseLeave };
|
||||
}
|
||||
|
||||
export interface CitationProps extends SerializableCitation {
|
||||
variant?: CitationVariant;
|
||||
className?: string;
|
||||
|
|
@ -99,13 +76,6 @@ export function Citation(props: CitationProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (sanitizedHref && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
const iconElement = favicon ? (
|
||||
<NextImage
|
||||
src={favicon}
|
||||
|
|
@ -120,13 +90,14 @@ export function Citation(props: CitationProps) {
|
|||
<TypeIcon className="size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
||||
);
|
||||
|
||||
const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover();
|
||||
|
||||
// Inline variant: compact chip with hover popover
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<CitationHoverPopover
|
||||
id={id}
|
||||
contentClassName="w-72 cursor-pointer p-0"
|
||||
onContentClick={handleClick}
|
||||
trigger={(hoverProps) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
|
|
@ -134,8 +105,7 @@ export function Citation(props: CitationProps) {
|
|||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...hoverProps}
|
||||
className={cn(
|
||||
"h-auto cursor-pointer gap-1.5 rounded-md px-2 py-1",
|
||||
"bg-muted/60 text-sm outline-none",
|
||||
|
|
@ -148,34 +118,73 @@ export function Citation(props: CitationProps) {
|
|||
{iconElement}
|
||||
<span className="text-muted-foreground">{domain}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-72 cursor-pointer p-0"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
)}
|
||||
>
|
||||
<div className="hover:bg-accent hover:text-accent-foreground flex flex-col gap-2 p-3 transition-colors">
|
||||
<div className="flex items-start gap-2">
|
||||
{iconElement}
|
||||
<span className="text-muted-foreground text-xs">{domain}</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-sm leading-snug font-medium">{title}</p>
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CitationHoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
const cardClassName = cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer no-underline",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
]
|
||||
);
|
||||
|
||||
const cardContent = (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Default variant: full card
|
||||
return (
|
||||
<article
|
||||
|
|
@ -184,59 +193,22 @@ export function Citation(props: CitationProps) {
|
|||
data-tool-ui-id={id}
|
||||
data-slot="citation"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: div receives role="link" conditionally when href is present */}
|
||||
<div
|
||||
className={cn(
|
||||
"group @container relative isolate flex w-full min-w-0 flex-col overflow-hidden rounded-xl",
|
||||
"border-border bg-card border text-sm shadow-xs",
|
||||
"transition-colors duration-150",
|
||||
sanitizedHref && [
|
||||
"cursor-pointer",
|
||||
"hover:border-foreground/25",
|
||||
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
]
|
||||
)}
|
||||
onClick={sanitizedHref ? handleClick : undefined}
|
||||
role={sanitizedHref ? "link" : undefined}
|
||||
tabIndex={sanitizedHref ? 0 : undefined}
|
||||
onKeyDown={sanitizedHref ? handleKeyDown : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<div className="text-muted-foreground flex min-w-0 items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{iconElement}
|
||||
<span className="truncate font-medium">{domain}</span>
|
||||
{(author || publishedAt) && (
|
||||
<span className="opacity-70">
|
||||
<span className="opacity-60"> — </span>
|
||||
{author}
|
||||
{author && publishedAt && ", "}
|
||||
{publishedAt && (
|
||||
<time dateTime={publishedAt} className="tabular-nums">
|
||||
{formatDate(publishedAt, locale)}
|
||||
</time>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{sanitizedHref && (
|
||||
<ExternalLink className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-foreground text-[15px] leading-snug font-medium text-pretty">
|
||||
<span className="group-hover:decoration-foreground/30 line-clamp-2 group-hover:underline group-hover:underline-offset-2">
|
||||
{title}
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed text-pretty">
|
||||
<span className="line-clamp-3">{snippet}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{sanitizedHref ? (
|
||||
<a
|
||||
href={sanitizedHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cardClassName}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleClick();
|
||||
}}
|
||||
>
|
||||
{cardContent}
|
||||
</a>
|
||||
) : (
|
||||
<div className={cardClassName}>{cardContent}</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue