import { useState, useEffect, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { motion } from 'motion/react'; import { Play, Pause, X } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { DiscussionAction } from '@/lib/types/action'; interface ProactiveCardProps { action: DiscussionAction; mode: 'playback' | 'paused' | 'autonomous'; /** Ref to the anchor element the card points to (avatar, etc.) */ anchorRef: React.RefObject; /** Where the card prefers to align relative to the anchor */ align?: 'left' | 'right'; /** Portal target — defaults to document.body. Pass the fullscreen container * when in presentation mode so the card stays visible inside the top-layer. */ portalContainer?: HTMLElement | null; agentName?: string; agentAvatar?: string; agentColor?: string; onSkip: () => void; onListen: () => void; onTogglePause: () => void; } const CARD_WIDTH = 256; // w-64 const VIEWPORT_PAD = 12; /** * 主动讨论卡片组件 * * 通过 React Portal 渲染到 document.body,使用 fixed 定位, * 不受父级 overflow/z-index stacking context 影响。 */ export const ProactiveCard = ({ action, mode, anchorRef, align = 'right', portalContainer, agentName, agentAvatar, agentColor, onSkip, onListen, onTogglePause, }: ProactiveCardProps) => { const { t } = useI18n(); const [progress, setProgress] = useState(100); const skippedRef = useRef(false); const isPaused = mode === 'paused'; // Computed position state const [pos, setPos] = useState<{ left: number; bottom: number; tailOffset: number; } | null>(null); const updatePosition = useCallback(() => { const el = anchorRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const anchorCenterX = rect.left + rect.width / 2; const anchorTop = rect.top; // Center card on anchor, clamped to viewport let cardLeft = anchorCenterX - CARD_WIDTH / 2; cardLeft = Math.max( VIEWPORT_PAD, Math.min(window.innerWidth - CARD_WIDTH - VIEWPORT_PAD, cardLeft), ); const tailOffset = Math.max(16, Math.min(CARD_WIDTH - 16, anchorCenterX - cardLeft)); const bottom = window.innerHeight - anchorTop + 12; // 12px gap above anchor setPos({ left: cardLeft, bottom, tailOffset }); }, [anchorRef]); // Continuously track anchor position via rAF to handle CSS transitions, sidebar collapse, etc. useEffect(() => { let rafId: number; const tick = () => { updatePosition(); rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); }, [updatePosition]); useEffect(() => { if (mode !== 'playback') return; const duration = 5000; const interval = 50; const step = (interval / duration) * 100; const timer = setInterval(() => { setProgress((prev) => { const newProgress = prev - step; if (newProgress <= 0) { clearInterval(timer); return 0; } return newProgress; }); }, interval); return () => clearInterval(timer); }, [mode]); useEffect(() => { if (progress <= 0 && !skippedRef.current && mode === 'playback') { skippedRef.current = true; onSkip(); } }, [progress, onSkip, mode]); if (!pos) return null; const card = (
{/* Close button */} {/* Triangle Tail */}
{/* Card body */}
{/* Progress Bar */}
{/* Header */}
{agentAvatar && (
{agentName
)}
{agentName && ( {agentName} )} {t('proactiveCard.discussion')}
{Math.max(0, Math.ceil((progress / 100) * 5))}s

{action.topic}

); return createPortal(card, portalContainer || document.body); };