// HoverCard: delayed hover-preview card portaled to document.body. // Same portal mechanics as Menu: the wrapper span supplies the anchor rect, // the card is fixed-positioned at its right edge and repositions on // scroll/resize while open. The card is reachable: it takes pointer events, // and leaving the anchor only arms a grace-delayed close, so the pointer can // cross the 8px gap and settle on the card to read a clipped path or title. // The portaled card is a React child of the wrapper, so React's enter/leave // traversal already treats it as inside — one pair of wrapper handlers covers // anchor and card alike. import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.disabled - suppress opening; turning true closes an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - accessible activation-label prefix (default "复制"). * @param props.copiedLabel - visible success label (default "复制成功"). * @returns anchor wrapper with the conditional portaled card. */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, copyText, copyLabel = '复制', copiedLabel = '复制成功', }: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean copyText?: string | undefined copyLabel?: string | undefined copiedLabel?: string | undefined }) { const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) const copyingRef = useRef(false) const mountedRef = useRef(true) const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ left: number; top: number } | null>(null) const [copied, setCopied] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { clearTimeout(copyTimerRef.current) copyTimerRef.current = null } copyHeightRef.current = null setCopied(false) }, []) const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() setOpen(false) }, [clearCopied]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) const clearTimer = () => { if (timerRef.current !== null) { clearTimeout(timerRef.current) timerRef.current = null } } // Owner disabling mid-hover (menu opened, drag started) closes immediately. useEffect(() => { if (!disabled) return clearTimer() cancelClose() close() }, [disabled, cancelClose, close]) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false copyEpochRef.current += 1 clearTimer() if (copyTimerRef.current !== null) { clearTimeout(copyTimerRef.current) copyTimerRef.current = null } } }, []) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { if (!open) { setPos(null); return } const place = () => { const wrapper = rootRef.current /* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */ if (wrapper === null) return const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) } place() window.addEventListener('scroll', place, true) window.addEventListener('resize', place) return () => { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } }, [open]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { if (!open || pos === null) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } }, [open, pos]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return copyingRef.current = true const copyEpoch = copyEpochRef.current const accepted = await writeClipboard(text) copyingRef.current = false const card = cardRef.current if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return const height = card.offsetHeight copyHeightRef.current = height > 0 ? height : null setCopied(true) copyTimerRef.current = setTimeout(clearCopied, 1000) } const copyable = copyText !== undefined const card = open && pos !== null && (
{ const selection = window.getSelection() if (selection !== null && !selection.isCollapsed) { for (let i = 0; i < selection.rangeCount; i += 1) { if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return } } void copy(copyText) } : undefined} onKeyDown={copyable ? (e) => { if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() void copy(copyText) } : undefined} > {copied ? : content}
) return ( { if (disabled) return // Coming back inside during the grace (the gap, or the card itself) // keeps the current card rather than restarting the dwell. cancelClose() if (open) return clearTimer() timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) }} onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while // open, matching Menu's shape. if (open) armClose() }} // A press inside the anchor (row click, menu trigger) dismisses the // card immediately, without waiting for the owner to flip `disabled`. // Capture presses reach this handler from the card too — it is a React // child of the wrapper — but a press there starts a selection, so the // card must stay mounted under it (and the browser's click with it). onPointerDownCapture={(e) => { if (cardRef.current?.contains(e.target as Node)) return clearTimer() cancelClose() close() }} > {anchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} ) }