217 lines
8.2 KiB
TypeScript
217 lines
8.2 KiB
TypeScript
// 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<HTMLSpanElement>(null)
|
|
const cardRef = useRef<HTMLDivElement>(null)
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const copyHeightRef = useRef<number | null>(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<void> => {
|
|
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 && (
|
|
<div
|
|
ref={cardRef}
|
|
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
|
|
style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
|
|
role={copyable ? 'button' : undefined}
|
|
tabIndex={copyable ? 0 : undefined}
|
|
aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
|
|
onClick={copyable
|
|
? (e) => {
|
|
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 ? <span className={css.copied} aria-hidden="true">{copiedLabel}</span> : content}
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<span
|
|
ref={rootRef}
|
|
className={css.root}
|
|
onPointerEnter={() => {
|
|
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 && <span className={css.status} role="status">{copied ? copiedLabel : ''}</span>}
|
|
{card !== false && createPortal(card, document.body)}
|
|
</span>
|
|
)
|
|
}
|