"use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { citationAnchor } from "../../lib/citations/inline"; import { CITATION_KIND_LABELS, type CitationView } from "../../lib/report/views"; import { CitationCard } from "./CitationCard"; import { useCitation } from "./CitationsContext"; // AN INLINE CITATION — what a `[label](cite:)` link in report markdown // renders as (CitedMarkdown): the label, linked to what it cites (the moment // page of a span or a post, the source's or the page's URL), then the // citation's number in its report as a superscript, linked to its reference // entry (`#c-`). // // THE PREVIEW: hovering the marker, or focusing the number, opens the // citation's card (CitationCard `preview`) beside it without leaving the text. // On touch the first tap on the number opens the preview and the second // follows the link, so a phone reader can peek before jumping. Escape, a tap // outside, or focus leaving the marker closes it. // // The card is PORTALLED to , never rendered inside the marker: the // marker sits inside a markdown

, and a card's blocks (blockquote, div) // inside a

are invalid HTML the parser would re-nest — a hydration error. // It is positioned against the document (so it scrolls with the text), under // the number, or above it when there is no room below, and kept inside the // viewport's width. The number is the keyboard's way in; its reference entry // carries the same card with every link. // // THE VIEW comes from the page's CitationsProvider by `id` (one map for the // page, never a copy per marker in the RSC payload); a caller outside a // provider may pass the `citation` itself. A ref neither resolves (no view for // the id — validation keeps a published report from having one) renders its // label alone. const CLOSE_DELAY_MS = 150; const EDGE_PX = 8; const GAP_PX = 6; function isExternal(c: CitationView): boolean { return c.kind === "source" || c.kind === "page"; } export function InlineCite({ citation: given, id, children, }: { citation?: CitationView; id?: string; children: ReactNode; }) { const fromMap = useCitation(given ? undefined : id); const citation = given ?? fromMap; const previewId = useId(); const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const wrapRef = useRef(null); const numberRef = useRef(null); const cardRef = useRef(null); const closeTimer = useRef | null>(null); const lastPointer = useRef("mouse"); const cancelClose = useCallback(() => { if (closeTimer.current) clearTimeout(closeTimer.current); closeTimer.current = null; }, []); const show = useCallback(() => { cancelClose(); setOpen(true); }, [cancelClose]); const hide = useCallback(() => { cancelClose(); setOpen(false); setPos(null); }, [cancelClose]); const hideSoon = useCallback(() => { cancelClose(); closeTimer.current = setTimeout(hide, CLOSE_DELAY_MS); }, [cancelClose, hide]); // Place the card once it has a size: under the number, or above it when the // viewport has no room below; clamped to the viewport's width. useLayoutEffect(() => { if (!open || !cardRef.current) return; const anchor = (numberRef.current ?? wrapRef.current)?.getBoundingClientRect(); if (!anchor) return; const card = cardRef.current.getBoundingClientRect(); const below = anchor.bottom + GAP_PX; const top = below + card.height > window.innerHeight - EDGE_PX && anchor.top - GAP_PX - card.height > EDGE_PX ? anchor.top - GAP_PX - card.height : below; const maxLeft = Math.max(EDGE_PX, window.innerWidth - card.width - EDGE_PX); const left = Math.min(Math.max(EDGE_PX, anchor.left - 12), maxLeft); setPos({ top: top + window.scrollY, left: left + window.scrollX }); }, [open]); // A tap or click outside the marker and the card closes it; so does a resize. useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { const t = e.target as Node; if (wrapRef.current?.contains(t) || cardRef.current?.contains(t)) return; hide(); }; document.addEventListener("pointerdown", onDown); window.addEventListener("resize", hide); return () => { document.removeEventListener("pointerdown", onDown); window.removeEventListener("resize", hide); }; }, [open, hide]); useEffect(() => cancelClose, [cancelClose]); if (!citation) return <>{children}; const c = citation; const external = isExternal(c); const n = c.number; return ( { if (lastPointer.current !== "touch") show(); }} onMouseLeave={() => { if (lastPointer.current !== "touch") hideSoon(); }} onFocus={show} onBlur={(e) => { const next = e.relatedTarget as Node | null; if (!wrapRef.current?.contains(next) && !cardRef.current?.contains(next)) hideSoon(); }} onKeyDown={(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); hide(); } }} onPointerDown={(e) => { lastPointer.current = e.pointerType; }} > {c.href ? ( {children} ) : ( children )} {n !== undefined && ( { // First tap on touch peeks; the second follows the link. if (lastPointer.current === "touch" && !open) { e.preventDefault(); show(); } }} className="rounded-sm px-0.5 text-brand-strong no-underline hover:bg-brand-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > [{n}] )} {open && createPortal(

{ if (lastPointer.current !== "touch") hideSoon(); }} >
, document.body, )} ); }