144 lines
4.0 KiB
TypeScript
144 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { useTheme } from "./ThemeContext";
|
|
|
|
interface CitationBeamOverlayProps {
|
|
activeCitationIndex: number | null;
|
|
}
|
|
|
|
interface Coords {
|
|
x1: number;
|
|
y1: number;
|
|
x2: number;
|
|
y2: number;
|
|
}
|
|
|
|
export function CitationBeamOverlay({ activeCitationIndex }: CitationBeamOverlayProps) {
|
|
const { resolvedTheme } = useTheme();
|
|
const [coords, setCoords] = useState<Coords | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!activeCitationIndex) {
|
|
setCoords(null);
|
|
return;
|
|
}
|
|
|
|
const updateCoords = () => {
|
|
const markerEl = document.getElementById(`citation-marker-${activeCitationIndex}`);
|
|
const cardEl = document.getElementById(`citation-card-${activeCitationIndex}`);
|
|
|
|
if (!markerEl || !cardEl) {
|
|
setCoords(null);
|
|
return;
|
|
}
|
|
|
|
const markerRect = markerEl.getBoundingClientRect();
|
|
const cardRect = cardEl.getBoundingClientRect();
|
|
|
|
// Ensure both elements are visible on screen
|
|
if (markerRect.width === 0 || cardRect.width === 0) {
|
|
setCoords(null);
|
|
return;
|
|
}
|
|
|
|
setCoords({
|
|
x1: markerRect.left + markerRect.width / 2,
|
|
y1: markerRect.top + markerRect.height / 2,
|
|
x2: cardRect.left,
|
|
y2: cardRect.top + cardRect.height / 2,
|
|
});
|
|
};
|
|
|
|
updateCoords();
|
|
const handleScrollOrResize = () => updateCoords();
|
|
|
|
window.addEventListener("resize", handleScrollOrResize);
|
|
window.addEventListener("scroll", handleScrollOrResize, true);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleScrollOrResize);
|
|
window.removeEventListener("scroll", handleScrollOrResize, true);
|
|
};
|
|
}, [activeCitationIndex]);
|
|
|
|
if (!activeCitationIndex || !coords) return null;
|
|
|
|
// Compute smooth bezier curve control points
|
|
const dx = Math.abs(coords.x2 - coords.x1);
|
|
const cx1 = coords.x1 + dx * 0.4;
|
|
const cy1 = coords.y1;
|
|
const cx2 = coords.x2 - dx * 0.4;
|
|
const cy2 = coords.y2;
|
|
|
|
const pathD = `M ${coords.x1} ${coords.y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${coords.x2} ${coords.y2}`;
|
|
|
|
const isGlass = resolvedTheme === "glass";
|
|
|
|
return (
|
|
<svg
|
|
className="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
|
|
aria-hidden="true"
|
|
>
|
|
<defs>
|
|
{/* Luminous Specular Gradient */}
|
|
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="var(--accent-primary)" stopOpacity="0.8" />
|
|
<stop offset="50%" stopColor="#38BDF8" stopOpacity="1" />
|
|
<stop offset="100%" stopColor="var(--accent-primary)" stopOpacity="0.8" />
|
|
</linearGradient>
|
|
|
|
<filter id="beamGlow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation={isGlass ? "6" : "3"} result="blur" />
|
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Background Outer Glow Line */}
|
|
<path
|
|
d={pathD}
|
|
fill="none"
|
|
stroke="var(--accent-glow)"
|
|
strokeWidth={isGlass ? "8" : "4"}
|
|
strokeLinecap="round"
|
|
filter="url(#beamGlow)"
|
|
className="opacity-70"
|
|
/>
|
|
|
|
{/* Main Connection Path Line */}
|
|
<path
|
|
d={pathD}
|
|
fill="none"
|
|
stroke="url(#beamGradient)"
|
|
strokeWidth={isGlass ? "3" : "2"}
|
|
strokeDasharray={isGlass ? "8 4" : "none"}
|
|
strokeLinecap="round"
|
|
className={isGlass ? "animate-pulse-beam" : "opacity-90"}
|
|
/>
|
|
|
|
{/* Start Dot at Citation Marker */}
|
|
<circle
|
|
cx={coords.x1}
|
|
cy={coords.y1}
|
|
r={isGlass ? "6" : "4"}
|
|
fill="var(--accent-primary)"
|
|
className="animate-ping opacity-75"
|
|
/>
|
|
<circle
|
|
cx={coords.x1}
|
|
cy={coords.y1}
|
|
r="4"
|
|
fill="#FFFFFF"
|
|
/>
|
|
|
|
{/* End Dot at Citation Card */}
|
|
<circle
|
|
cx={coords.x2}
|
|
cy={coords.y2}
|
|
r={isGlass ? "6" : "4"}
|
|
fill="#38BDF8"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|