'use client' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { RefObject, ReactNode } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { cn } from '@/lib/utils' import { Tooltip } from '../basic/tooltip' // ============================================================================ // 对话锚点导航条(Conversation Anchor Nav) // ---------------------------------------------------------------------------- // 固定在长对话流容器右侧居中,为每条用户消息(或任何可定位节点)渲染一枚短横杠 // 或「打孔」圆点。hover 时横杠加长 / 圆点放大并展示摘要 tooltip,点击触发 onSelect。 // 当前可视区域对应的锚点自动高亮,数量不足 minItems 或 hidden=true 时自动隐藏。 // ============================================================================ /** 单个锚点项 */ export interface ConversationAnchorItem { /** 稳定 id,用于定位 DOM 节点与 React key */ id: string /** 在 Tooltip 中展示的摘要文案(纯文本)。为空时回落到 labels.scrollTo。 */ summary?: string } /** i18n 文案 */ export interface ConversationAnchorLabels { /** nav 的 aria-label */ ariaLabel?: string /** Tooltip 兜底文案(无摘要时) */ scrollTo?: string } /** 样式变体 */ export type ConversationAnchorVariant = 'bar' | 'punch' export interface ConversationAnchorNavProps { /** 滚动容器 ref,用于检测当前可视锚点;受控场景可传 { current: null } */ scrollContainerRef: RefObject /** 锚点列表,顺序即视觉顺序 */ items: ConversationAnchorItem[] /** 点击锚点时回调,参数为在 items 中的索引与对应 item */ onSelect: (index: number, item: ConversationAnchorItem) => void /** * 当前高亮索引(受控)。 * 省略时组件内部会订阅 scrollContainerRef 的滚动并通过 IntersectionObserver 自动推导。 */ activeIndex?: number /** * 锚点视觉样式: * - 'bar' (默认):短横杠,hover 时加长 * - 'punch':圆形"打孔"点,适合羊皮纸 / 拟物风主题 */ variant?: ConversationAnchorVariant /** 少于该数量时隐藏整个 nav,默认 3 */ minItems?: number /** 外部强制隐藏(例如 compact 布局),默认 false */ hidden?: boolean /** 自定义 Tooltip / aria 文案 */ labels?: ConversationAnchorLabels /** 额外 className(会与默认定位 className 合并) */ className?: string /** * 自定义锚点元素解析。默认通过 * `scrollContainerRef.current.querySelector('[data-spark-anchor=""]')` * 查找;需要业务在消息容器上添加该属性。 */ getAnchorElement?: ( item: ConversationAnchorItem, index: number, container: HTMLElement, ) => HTMLElement | null /** 渲染 slot:自定义每个锚点的视觉表达,返回 null 时回落到内置样式 */ renderAnchor?: (ctx: { item: ConversationAnchorItem index: number isActive: boolean variant: ConversationAnchorVariant }) => ReactNode } const DEFAULT_LABELS: Required = { ariaLabel: 'Conversation anchors', scrollTo: 'Scroll to message', } export const ConversationAnchorNav = memo(function ConversationAnchorNav({ scrollContainerRef, items, onSelect, activeIndex: controlledActiveIndex, variant = 'bar', minItems = 3, hidden = false, labels, className, getAnchorElement, renderAnchor, }: ConversationAnchorNavProps) { const mergedLabels = { ...DEFAULT_LABELS, ...labels } const detectedActiveIndex = useVisibleAnchorIndex({ scrollContainerRef, items, getAnchorElement, enabled: controlledActiveIndex === undefined && !hidden, }) const activeIndex = controlledActiveIndex !== undefined ? controlledActiveIndex : detectedActiveIndex const handleClick = useCallback( (index: number) => { const item = items[index] if (item) onSelect(index, item) }, [items, onSelect], ) const shouldShow = !hidden && items.length >= minItems return ( {shouldShow && ( {items.map((item, index) => ( ))} )} ) }) ConversationAnchorNav.displayName = 'ConversationAnchorNav' // ---------------------------------------------------------------------------- // 单个锚点 // ---------------------------------------------------------------------------- interface AnchorDotProps { item: ConversationAnchorItem index: number isActive: boolean variant: ConversationAnchorVariant tooltipFallback: string onClick: (index: number) => void renderAnchor?: ConversationAnchorNavProps['renderAnchor'] } const AnchorDot = memo( function AnchorDot({ item, index, isActive, variant, tooltipFallback, onClick, renderAnchor, }: AnchorDotProps) { const handleClick = useCallback(() => onClick(index), [onClick, index]) const custom = renderAnchor ? renderAnchor({ item, index, isActive, variant }) : null return ( ) }, (prev, next) => prev.isActive === next.isActive && prev.variant === next.variant && prev.index === next.index && prev.item.id === next.item.id && prev.item.summary === next.item.summary && prev.tooltipFallback === next.tooltipFallback && prev.onClick === next.onClick && prev.renderAnchor === next.renderAnchor, ) // ---------------------------------------------------------------------------- // Hook: 基于 IntersectionObserver 推导当前可视锚点索引 // ---------------------------------------------------------------------------- export interface UseVisibleAnchorIndexOptions { scrollContainerRef: RefObject items: ConversationAnchorItem[] /** 与 ConversationAnchorNav 同名 prop,默认按 `[data-spark-anchor=""]` 查找 */ getAnchorElement?: ConversationAnchorNavProps['getAnchorElement'] /** 为 false 时不订阅滚动,返回 0;用于受控场景 */ enabled?: boolean } /** * 订阅 scroll 容器内每个锚点元素的可见性,返回"最靠近顶部且仍可见"的锚点索引。 * - 容器必须可滚动(通常 overflow-y: auto),且锚点元素是其后代 * - 每个锚点需要能通过 `getAnchorElement` 解析到 HTMLElement */ export function useVisibleAnchorIndex({ scrollContainerRef, items, getAnchorElement, enabled = true, }: UseVisibleAnchorIndexOptions): number { const [activeIndex, setActiveIndex] = useState(0) // 稳定化 ids 作为 effect 依赖,避免每次 items 引用变化都重建 observer const idsKey = useMemo(() => items.map((i) => i.id).join('|'), [items]) const getAnchorRef = useRef(getAnchorElement) useEffect(() => { getAnchorRef.current = getAnchorElement }, [getAnchorElement]) useEffect(() => { if (!enabled) return const container = scrollContainerRef.current if (!container || items.length === 0) return if (typeof IntersectionObserver === 'undefined') return const resolve = (item: ConversationAnchorItem, index: number) => { const fn = getAnchorRef.current if (fn) return fn(item, index, container) return container.querySelector( `[data-spark-anchor="${cssEscape(item.id)}"]`, ) } const anchors: Array<{ index: number; el: HTMLElement }> = [] items.forEach((item, index) => { const el = resolve(item, index) if (el) anchors.push({ index, el }) }) if (anchors.length === 0) return const ratioByEl = new WeakMap() const recompute = () => { let bestIndex = anchors[0]?.index ?? 0 let bestRatio = -1 for (const { index, el } of anchors) { const r = ratioByEl.get(el) ?? 0 if (r > bestRatio) { bestRatio = r bestIndex = index } } setActiveIndex((prev) => (prev === bestIndex ? prev : bestIndex)) } const observer = new IntersectionObserver( (entries) => { for (const e of entries) { ratioByEl.set(e.target as HTMLElement, e.intersectionRatio) } recompute() }, { root: container, threshold: [0, 0.25, 0.5, 0.75, 1] }, ) anchors.forEach((a) => observer.observe(a.el)) return () => observer.disconnect() // idsKey 稳定覆盖 items 内容变化;scrollContainerRef 本身稳定 }, [enabled, scrollContainerRef, items, idsKey]) return activeIndex } function cssEscape(value: string): string { if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { return CSS.escape(value) } return value.replace(/["\\]/g, '\\$&') }